generated from Nodarx/template
@@ -0,0 +1,35 @@
|
|||||||
|
name: Renovate
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
renovate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6.0.2
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v6.2.0
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Set up PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.5'
|
||||||
|
tools: composer:v2
|
||||||
|
|
||||||
|
- name: Install Renovate
|
||||||
|
run: npm install -g renovate
|
||||||
|
|
||||||
|
- name: Run Renovate
|
||||||
|
env:
|
||||||
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
|
RENOVATE_PLATFORM: gitea
|
||||||
|
RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1
|
||||||
|
run: renovate ${{ gitea.repository }}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
# Frontend development
|
||||||
|
node_modules/
|
||||||
|
*.local
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.cache/
|
||||||
|
.vite/
|
||||||
|
.temp/
|
||||||
|
.tmp/
|
||||||
|
|
||||||
|
# Frontend build
|
||||||
|
/static/
|
||||||
|
|
||||||
|
# Backend development
|
||||||
|
/lib/vendor/
|
||||||
|
coverage/
|
||||||
|
phpunit.xml.cache
|
||||||
|
.phpunit.result.cache
|
||||||
|
.php-cs-fixer.cache
|
||||||
|
.phpstan.cache
|
||||||
|
.phpactor/
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.ideas/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "ktrix/media-viewer",
|
||||||
|
"description": "Media viewer module — provides inline image and video preview at the file viewer integration point, reusable across modules",
|
||||||
|
"type": "ktrix-module",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"KTXM\\MediaViewer\\": "lib/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"require": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXM\MediaViewer;
|
||||||
|
|
||||||
|
use KTXF\Module\ModuleBrowserInterface;
|
||||||
|
use KTXF\Module\ModuleInstanceAbstract;
|
||||||
|
|
||||||
|
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
||||||
|
{
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{ }
|
||||||
|
|
||||||
|
public function handle(): string
|
||||||
|
{
|
||||||
|
return 'media_viewer';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Media Viewer';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function author(): string
|
||||||
|
{
|
||||||
|
return 'Ktrix';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Provides inline image and video preview via the file viewer integration point, reusable across modules such as Documents and Mail.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function version(): string
|
||||||
|
{
|
||||||
|
return '0.0.1';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function permissions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerBI(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'handle' => $this->handle(),
|
||||||
|
'namespace' => 'MediaViewer',
|
||||||
|
'version' => $this->version(),
|
||||||
|
'label' => $this->label(),
|
||||||
|
'author' => $this->author(),
|
||||||
|
'description' => $this->description(),
|
||||||
|
'boot' => 'static/module.mjs',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1599
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "media_viewer",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"author": "Ktrix",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build --mode production --config vite.config.ts",
|
||||||
|
"dev": "vite build --mode development --config vite.config.ts",
|
||||||
|
"watch": "vite build --mode development --watch --config vite.config.ts",
|
||||||
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pinia": "^2.3.1",
|
||||||
|
"vue": "^3.5.18",
|
||||||
|
"vue-router": "^4.5.1",
|
||||||
|
"vuetify": "^3.10.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vue/tsconfig": "^0.7.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "^7.1.2",
|
||||||
|
"vue-tsc": "^3.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["config:recommended"],
|
||||||
|
"enabledManagers": ["npm", "composer", "github-actions"],
|
||||||
|
"timezone": "UTC",
|
||||||
|
"schedule": ["* 0-3 * * *"],
|
||||||
|
"dependencyDashboard": true,
|
||||||
|
"prConcurrentLimit": 5,
|
||||||
|
"prHourlyLimit": 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import type { MediaLeafProps } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<MediaLeafProps>()
|
||||||
|
|
||||||
|
// ── Pan / zoom state ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const scale = ref(1)
|
||||||
|
const translateX = ref(0)
|
||||||
|
const translateY = ref(0)
|
||||||
|
const isDragging = ref(false)
|
||||||
|
|
||||||
|
const MIN_SCALE = 0.1
|
||||||
|
const MAX_SCALE = 10
|
||||||
|
const ZOOM_FACTOR = 0.12
|
||||||
|
|
||||||
|
let dragStartX = 0
|
||||||
|
let dragStartY = 0
|
||||||
|
let dragStartTX = 0
|
||||||
|
let dragStartTY = 0
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// ── Zoom ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function clamp(val: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, val))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWheel(e: WheelEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const delta = e.deltaY > 0 ? -(ZOOM_FACTOR) : ZOOM_FACTOR
|
||||||
|
scale.value = clamp(scale.value * (1 + delta), MIN_SCALE, MAX_SCALE)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drag (pan) ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleMousedown(e: MouseEvent) {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
isDragging.value = true
|
||||||
|
dragStartX = e.clientX
|
||||||
|
dragStartY = e.clientY
|
||||||
|
dragStartTX = translateX.value
|
||||||
|
dragStartTY = translateY.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMousemove(e: MouseEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
translateX.value = dragStartTX + (e.clientX - dragStartX)
|
||||||
|
translateY.value = dragStartTY + (e.clientY - dragStartY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseup() {
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDblclick() {
|
||||||
|
// Double-click to reset
|
||||||
|
scale.value = 1
|
||||||
|
translateX.value = 0
|
||||||
|
translateY.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pan/zoom is for the full viewer only; the hover popover shows a static fit.
|
||||||
|
const interactive = computed(() => props.presentation !== 'popover')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!interactive.value) return
|
||||||
|
const el = containerRef.value
|
||||||
|
if (el) {
|
||||||
|
el.addEventListener('wheel', handleWheel, { passive: false })
|
||||||
|
}
|
||||||
|
window.addEventListener('mousemove', handleMousemove)
|
||||||
|
window.addEventListener('mouseup', handleMouseup)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (!interactive.value) return
|
||||||
|
const el = containerRef.value
|
||||||
|
if (el) {
|
||||||
|
el.removeEventListener('wheel', handleWheel)
|
||||||
|
}
|
||||||
|
window.removeEventListener('mousemove', handleMousemove)
|
||||||
|
window.removeEventListener('mouseup', handleMouseup)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
class="image-viewer"
|
||||||
|
:class="{ 'image-viewer--dragging': isDragging, 'image-viewer--static': !interactive }"
|
||||||
|
@mousedown="interactive && handleMousedown($event)"
|
||||||
|
@dblclick="interactive && handleDblclick()"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="url"
|
||||||
|
:alt="title ?? item?.title ?? 'Preview image'"
|
||||||
|
class="image-viewer-img"
|
||||||
|
:style="{
|
||||||
|
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
}"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Zoom hint -->
|
||||||
|
<div v-if="interactive" class="image-viewer-hint text-caption text-medium-emphasis">
|
||||||
|
Scroll to zoom · Drag to pan · Double-click to reset
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.image-viewer {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer--dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer--static {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
transition: transform 0.05s linear;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer--dragging .image-viewer-img {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-hint {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
color: #fff;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer:hover .image-viewer-hint {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, defineAsyncComponent, onUnmounted } from 'vue'
|
||||||
|
import type { MediaItem, ResolveSource, MediaPresentation } from '@/types'
|
||||||
|
import { findViewer } from '@/services/viewer'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MediaStage renders a single MediaItem: it resolves the matching leaf viewer
|
||||||
|
* by MIME type, resolves the item's source (URL string used as-is, or a Blob
|
||||||
|
* turned into a tracked object URL), and renders the leaf. Surfaces (dialog,
|
||||||
|
* popover) wrap the stage and provide chrome.
|
||||||
|
*/
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
item: MediaItem | null
|
||||||
|
resolveSource: ResolveSource
|
||||||
|
presentation?: MediaPresentation
|
||||||
|
}>(),
|
||||||
|
{ presentation: 'fullscreen' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const viewer = computed(() =>
|
||||||
|
props.item?.mime ? findViewer(props.item.mime) : null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const viewerComponent = computed(() => {
|
||||||
|
if (!viewer.value?.component) return null
|
||||||
|
return defineAsyncComponent(viewer.value.component as () => Promise<unknown>)
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(false)
|
||||||
|
let objectUrl: string | null = null
|
||||||
|
|
||||||
|
function revoke() {
|
||||||
|
if (objectUrl) {
|
||||||
|
URL.revokeObjectURL(objectUrl)
|
||||||
|
objectUrl = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(item: MediaItem | null) {
|
||||||
|
revoke()
|
||||||
|
url.value = ''
|
||||||
|
error.value = false
|
||||||
|
if (!item || !viewer.value) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
const token = item.id
|
||||||
|
try {
|
||||||
|
const src = await props.resolveSource(item)
|
||||||
|
// Guard against races: a faster navigation may have changed the item.
|
||||||
|
if (props.item?.id !== token) return
|
||||||
|
if (typeof src === 'string') {
|
||||||
|
url.value = src
|
||||||
|
} else {
|
||||||
|
objectUrl = URL.createObjectURL(src)
|
||||||
|
url.value = objectUrl
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (props.item?.id === token) error.value = true
|
||||||
|
} finally {
|
||||||
|
if (props.item?.id === token) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.item, item => load(item), { immediate: true })
|
||||||
|
onUnmounted(revoke)
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
hasViewer: computed(() => !!viewer.value),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="media-stage">
|
||||||
|
<div v-if="loading" class="media-stage__state">
|
||||||
|
<v-progress-circular indeterminate color="primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Suspense v-else-if="viewerComponent && url">
|
||||||
|
<template #default>
|
||||||
|
<component
|
||||||
|
:is="viewerComponent"
|
||||||
|
:url="url"
|
||||||
|
:mime="item!.mime"
|
||||||
|
:title="item!.title"
|
||||||
|
:item="item"
|
||||||
|
:presentation="presentation"
|
||||||
|
class="media-stage__viewer"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #fallback>
|
||||||
|
<div class="media-stage__state">
|
||||||
|
<v-progress-circular indeterminate color="primary" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<slot v-else-if="error" name="error" :item="item">
|
||||||
|
<div class="media-stage__state">
|
||||||
|
<v-icon size="48" color="grey-lighten-1">mdi-alert-circle-outline</v-icon>
|
||||||
|
<p class="text-body-2 text-medium-emphasis mt-2">Failed to load preview</p>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
|
||||||
|
<slot
|
||||||
|
v-else
|
||||||
|
name="unsupported"
|
||||||
|
:item="item"
|
||||||
|
:mime="item?.mime ?? ''"
|
||||||
|
>
|
||||||
|
<div class="media-stage__state">
|
||||||
|
<v-icon size="48" color="grey-lighten-1">mdi-file-question-outline</v-icon>
|
||||||
|
<p class="text-body-2 text-medium-emphasis mt-2">No preview available</p>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.media-stage,
|
||||||
|
.media-stage__viewer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-stage {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-stage__state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import type { MediaItem, ResolveSource } from '@/types'
|
||||||
|
import { findViewer } from '@/services/viewer.ts'
|
||||||
|
import MediaStage from './MediaStage.vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fullscreen media viewer surface. Generic: driven entirely by `MediaItem`s and
|
||||||
|
* a host-supplied `resolveSource`. Prev/next navigation is opt-in via
|
||||||
|
* `navigation` and is suppressed for `standalone` viewers (which bring their
|
||||||
|
* own chrome) so their controls never compete with a "next file" arrow.
|
||||||
|
*/
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
items: MediaItem[]
|
||||||
|
index: number
|
||||||
|
resolveSource: ResolveSource
|
||||||
|
/** Triggers a host download of the given item. */
|
||||||
|
download?: (item: MediaItem) => void
|
||||||
|
/** Enable prev/next gallery navigation across `items`. */
|
||||||
|
navigation?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
download: undefined,
|
||||||
|
navigation: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
'update:index': [value: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const currentItem = computed<MediaItem | null>(
|
||||||
|
() => props.items[props.index] ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const filename = computed(() => currentItem.value?.title ?? '')
|
||||||
|
|
||||||
|
const activeMode = computed(() => {
|
||||||
|
const mime = currentItem.value?.mime
|
||||||
|
if (!mime) return undefined
|
||||||
|
return findViewer(mime)?.meta?.mode as string | undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const navVisible = computed(
|
||||||
|
() =>
|
||||||
|
props.navigation &&
|
||||||
|
props.items.length > 1 &&
|
||||||
|
activeMode.value !== 'standalone',
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasPrev = computed(() => navVisible.value && props.index > 0)
|
||||||
|
const hasNext = computed(
|
||||||
|
() => navVisible.value && props.index < props.items.length - 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
function navigatePrev() {
|
||||||
|
if (hasPrev.value) emit('update:index', props.index - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateNext() {
|
||||||
|
if (hasNext.value) emit('update:index', props.index + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDownload() {
|
||||||
|
if (currentItem.value) props.download?.(currentItem.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (!props.modelValue) return
|
||||||
|
if (e.key === 'ArrowLeft') { e.preventDefault(); navigatePrev() }
|
||||||
|
else if (e.key === 'ArrowRight') { e.preventDefault(); navigateNext() }
|
||||||
|
else if (e.key === 'Escape') { e.preventDefault(); close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener('keydown', handleKeydown))
|
||||||
|
onUnmounted(() => window.removeEventListener('keydown', handleKeydown))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
fullscreen
|
||||||
|
transition="dialog-bottom-transition"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<div class="viewer-shell">
|
||||||
|
|
||||||
|
<!-- ── Toolbar ─────────────────────────────────────────────────── -->
|
||||||
|
<div class="viewer-toolbar">
|
||||||
|
<v-btn icon="mdi-close" variant="text" @click="close" />
|
||||||
|
|
||||||
|
<span class="viewer-filename text-truncate">{{ filename }}</span>
|
||||||
|
|
||||||
|
<div class="viewer-toolbar-actions">
|
||||||
|
<span
|
||||||
|
v-if="navVisible"
|
||||||
|
class="viewer-counter text-caption text-medium-emphasis mr-2"
|
||||||
|
>
|
||||||
|
{{ index + 1 }} / {{ items.length }}
|
||||||
|
</span>
|
||||||
|
<v-btn
|
||||||
|
v-if="download"
|
||||||
|
icon="mdi-download"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
title="Download"
|
||||||
|
@click="handleDownload"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Content area ───────────────────────────────────────────── -->
|
||||||
|
<div class="viewer-content">
|
||||||
|
|
||||||
|
<!-- Prev arrow -->
|
||||||
|
<div class="viewer-nav viewer-nav--prev">
|
||||||
|
<v-btn
|
||||||
|
v-if="hasPrev"
|
||||||
|
icon="mdi-chevron-left"
|
||||||
|
variant="elevated"
|
||||||
|
size="large"
|
||||||
|
@click="navigatePrev"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stage -->
|
||||||
|
<div class="viewer-stage">
|
||||||
|
<MediaStage :item="currentItem" :resolve-source="resolveSource">
|
||||||
|
<template #unsupported="{ mime }">
|
||||||
|
<div class="viewer-no-preview">
|
||||||
|
<v-icon size="64" color="grey-lighten-1">mdi-file-question-outline</v-icon>
|
||||||
|
<p class="text-h6 mt-4">No preview available</p>
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-6">
|
||||||
|
{{ mime || 'Unknown file type' }}
|
||||||
|
</p>
|
||||||
|
<v-btn
|
||||||
|
v-if="download"
|
||||||
|
prepend-icon="mdi-download"
|
||||||
|
variant="tonal"
|
||||||
|
@click="handleDownload"
|
||||||
|
>
|
||||||
|
Download file
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</MediaStage>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Next arrow -->
|
||||||
|
<div class="viewer-nav viewer-nav--next">
|
||||||
|
<v-btn
|
||||||
|
v-if="hasNext"
|
||||||
|
icon="mdi-chevron-right"
|
||||||
|
variant="elevated"
|
||||||
|
size="large"
|
||||||
|
@click="navigateNext"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.viewer-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: rgb(var(--v-theme-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.viewer-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
min-height: 56px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-filename {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.viewer-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prev/Next nav columns */
|
||||||
|
.viewer-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 64px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main stage */
|
||||||
|
.viewer-stage {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No-preview fallback */
|
||||||
|
.viewer-no-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.viewer-nav {
|
||||||
|
width: 40px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { MediaItem, ResolveSource } from '@/types'
|
||||||
|
import { findViewer } from '@/services/viewer.ts'
|
||||||
|
import MediaStage from './MediaStage.vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight hover-preview surface. Mounted inside a host trigger element and
|
||||||
|
* anchored to it via Vuetify's `activator="parent"`, so the host only needs to
|
||||||
|
* drop this component into the element it wants previewed.
|
||||||
|
*
|
||||||
|
* The menu renders its content lazily, so the source (and any blob fetch) is
|
||||||
|
* only resolved once the menu actually opens after `openDelay` — a quick
|
||||||
|
* fly-over never triggers a fetch. Clicking the preview emits `expand` so the
|
||||||
|
* host can open the fullscreen viewer.
|
||||||
|
*/
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
item: MediaItem | null
|
||||||
|
resolveSource: ResolveSource
|
||||||
|
/** Hover dwell before opening, in ms. */
|
||||||
|
openDelay?: number
|
||||||
|
}>(),
|
||||||
|
{ openDelay: 400 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
expand: [item: MediaItem]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Only offer a hover preview when a matching viewer exists and opts in
|
||||||
|
// (meta.popover !== false). Standalone viewers default to opting out.
|
||||||
|
const enabled = computed(() => {
|
||||||
|
const mime = props.item?.mime
|
||||||
|
if (!mime) return false
|
||||||
|
const viewer = findViewer(mime)
|
||||||
|
return !!viewer && viewer.meta?.popover !== false
|
||||||
|
})
|
||||||
|
|
||||||
|
function expand() {
|
||||||
|
if (props.item) emit('expand', props.item)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-menu
|
||||||
|
v-if="enabled"
|
||||||
|
activator="parent"
|
||||||
|
open-on-hover
|
||||||
|
:open-delay="openDelay"
|
||||||
|
:close-delay="100"
|
||||||
|
location="end"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
>
|
||||||
|
<v-card class="media-popover" @click="expand">
|
||||||
|
<div class="media-popover__stage">
|
||||||
|
<MediaStage :item="item" :resolve-source="resolveSource" presentation="popover" />
|
||||||
|
</div>
|
||||||
|
<div class="media-popover__hint text-caption text-medium-emphasis">
|
||||||
|
Click to open
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</v-menu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.media-popover {
|
||||||
|
width: 320px;
|
||||||
|
max-width: 80vw;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-popover__stage {
|
||||||
|
width: 100%;
|
||||||
|
height: 220px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgb(var(--v-theme-surface-light, var(--v-theme-surface)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-popover__hint {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { MediaLeafProps } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<MediaLeafProps>()
|
||||||
|
|
||||||
|
// Don't autoplay in the lightweight hover popover.
|
||||||
|
const autoplay = computed(() => props.presentation !== 'popover')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="video-viewer">
|
||||||
|
<video
|
||||||
|
:src="url"
|
||||||
|
:type="mime"
|
||||||
|
class="video-viewer-player"
|
||||||
|
controls
|
||||||
|
:autoplay="autoplay"
|
||||||
|
playsinline
|
||||||
|
>
|
||||||
|
Your browser does not support HTML5 video.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.video-viewer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-viewer-player {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { default as MediaStage } from './MediaStage.vue'
|
||||||
|
export { default as MediaViewerDialog } from './MediaViewerDialog.vue'
|
||||||
|
export { default as MediaViewerPopover } from './MediaViewerPopover.vue'
|
||||||
|
export { default as ImageViewer } from './ImageViewer.vue'
|
||||||
|
export { default as VideoViewer } from './VideoViewer.vue'
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ModuleIntegrations } from '@KTXC/types/moduleTypes'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The media viewer registers:
|
||||||
|
*
|
||||||
|
* - leaf viewers at the generic `media_viewer_format` integration point, resolved by
|
||||||
|
* MIME type. Each declares `meta.mode` ('gallery' | 'standalone') and
|
||||||
|
* `meta.popover` (whether it may be shown in the hover popover surface).
|
||||||
|
* - the shell dialog at the `media_viewer` integration point (`id: 'dialog'`),
|
||||||
|
* so host modules (Documents, Mail, …) can resolve and mount it without
|
||||||
|
* importing this module's source.
|
||||||
|
*
|
||||||
|
* Leaf viewer prop contract:
|
||||||
|
* {
|
||||||
|
* url: string,
|
||||||
|
* mime: string,
|
||||||
|
* title?: string,
|
||||||
|
* item?: MediaItem | null
|
||||||
|
* }.
|
||||||
|
*/
|
||||||
|
const integrations: ModuleIntegrations = {
|
||||||
|
media_viewer_format: [
|
||||||
|
{
|
||||||
|
id: 'image',
|
||||||
|
label: 'Image Viewer',
|
||||||
|
priority: 10,
|
||||||
|
meta: {
|
||||||
|
mimePatterns: ['image/*'],
|
||||||
|
mode: 'gallery',
|
||||||
|
popover: true,
|
||||||
|
},
|
||||||
|
component: () => import('./components/ImageViewer.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'video',
|
||||||
|
label: 'Video Viewer',
|
||||||
|
priority: 10,
|
||||||
|
meta: {
|
||||||
|
mimePatterns: ['video/*'],
|
||||||
|
mode: 'gallery',
|
||||||
|
popover: true,
|
||||||
|
},
|
||||||
|
component: () => import('./components/VideoViewer.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
media_viewer: [
|
||||||
|
{
|
||||||
|
id: 'dialog',
|
||||||
|
label: 'Media Viewer Dialog',
|
||||||
|
priority: 10,
|
||||||
|
component: () => import('./components/MediaViewerDialog.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'popover',
|
||||||
|
label: 'Media Viewer Popover',
|
||||||
|
priority: 10,
|
||||||
|
component: () => import('./components/MediaViewerPopover.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default integrations
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import integrations from '@/integrations'
|
||||||
|
import type {
|
||||||
|
MediaItem,
|
||||||
|
MediaSource,
|
||||||
|
ResolveSource,
|
||||||
|
MediaLeafProps,
|
||||||
|
MediaPresentation,
|
||||||
|
MediaViewerMode,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
// CSS filename is injected by the vite plugin at build time
|
||||||
|
// The placeholder gets replaced with the actual hashed filename
|
||||||
|
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
|
||||||
|
|
||||||
|
export { integrations }
|
||||||
|
export type {
|
||||||
|
MediaItem,
|
||||||
|
MediaSource,
|
||||||
|
ResolveSource,
|
||||||
|
MediaLeafProps,
|
||||||
|
MediaPresentation,
|
||||||
|
MediaViewerMode,
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useIntegrationStore } from '@KTXC'
|
||||||
|
import type { ModuleIntegrationItem } from '@KTXC/types/moduleTypes'
|
||||||
|
|
||||||
|
export const MEDIA_VIEWER_FORMAT_POINT = 'media_viewer_format'
|
||||||
|
|
||||||
|
function mimeMatchesPattern(mime: string, pattern: string): boolean {
|
||||||
|
if (pattern.endsWith('/*')) {
|
||||||
|
// e.g. 'image/*' matches 'image/png', 'image/jpeg', …
|
||||||
|
return mime.startsWith(pattern.slice(0, -1))
|
||||||
|
}
|
||||||
|
return mime === pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewerMatchesMime(
|
||||||
|
mime: string,
|
||||||
|
mimeTypes?: string[],
|
||||||
|
mimePatterns?: string[],
|
||||||
|
): boolean {
|
||||||
|
if (mimeTypes?.includes(mime)) return true
|
||||||
|
if (mimePatterns) {
|
||||||
|
for (const pattern of mimePatterns) {
|
||||||
|
if (mimeMatchesPattern(mime, pattern)) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the highest-priority registered viewer that can handle `mime`,
|
||||||
|
* or `null` if none is found.
|
||||||
|
*/
|
||||||
|
export function findViewer(mime: string): ModuleIntegrationItem | null {
|
||||||
|
const integrationStore = useIntegrationStore()
|
||||||
|
// getItems() already returns items sorted by priority (ascending)
|
||||||
|
const viewers = integrationStore.getItems(MEDIA_VIEWER_FORMAT_POINT) as ModuleIntegrationItem[]
|
||||||
|
for (const viewer of viewers) {
|
||||||
|
if (
|
||||||
|
viewerMatchesMime(
|
||||||
|
mime,
|
||||||
|
viewer.meta?.mimeTypes as string[] | undefined,
|
||||||
|
viewer.meta?.mimePatterns as string[] | undefined,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return viewer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if any registered viewer can render this MIME type. */
|
||||||
|
export function hasViewer(mime: string): boolean {
|
||||||
|
if (!mime) return false
|
||||||
|
return findViewer(mime) !== null
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export interface MediaItem {
|
||||||
|
/** Stable identity, used for navigation keying. */
|
||||||
|
id: string
|
||||||
|
/** Display name (filename, attachment name, …). */
|
||||||
|
title: string
|
||||||
|
mime: string
|
||||||
|
size?: number | null
|
||||||
|
/** Host passthrough (e.g. collection id, blob selector). */
|
||||||
|
meta?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MediaSource = string | Blob
|
||||||
|
|
||||||
|
export type ResolveSource = (
|
||||||
|
item: MediaItem,
|
||||||
|
) => MediaSource | Promise<MediaSource>
|
||||||
|
|
||||||
|
/** Surface a host renders the stage in. */
|
||||||
|
export type MediaPresentation = 'popover' | 'fullscreen'
|
||||||
|
|
||||||
|
/** Props passed to a registered leaf viewer (ImageViewer, VideoViewer, …). */
|
||||||
|
export interface MediaLeafProps {
|
||||||
|
url: string
|
||||||
|
mime: string
|
||||||
|
title?: string
|
||||||
|
item?: MediaItem | null
|
||||||
|
/** Surface the leaf is rendered in; lets viewers adapt (e.g. no autoplay). */
|
||||||
|
presentation?: MediaPresentation
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Viewer behaviour declared in an integration item's `meta`. */
|
||||||
|
export type MediaViewerMode = 'gallery' | 'standalone'
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.app.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@KTXC/*": ["../../core/src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.tsx",
|
||||||
|
"src/**/*.vue",
|
||||||
|
"../../core/src/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.node.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"vite.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
{
|
||||||
|
name: 'inject-css-filename',
|
||||||
|
enforce: 'post',
|
||||||
|
generateBundle(_options, bundle) {
|
||||||
|
const cssFile = Object.keys(bundle).find(name => name.endsWith('.css'))
|
||||||
|
if (!cssFile) return
|
||||||
|
|
||||||
|
for (const fileName of Object.keys(bundle)) {
|
||||||
|
const chunk = bundle[fileName]
|
||||||
|
if (chunk.type === 'chunk' && chunk.code.includes('__CSS_FILENAME_PLACEHOLDER__')) {
|
||||||
|
chunk.code = chunk.code.replace(/__CSS_FILENAME_PLACEHOLDER__/g, `static/${cssFile}`)
|
||||||
|
console.log(`Injected CSS filename "static/${cssFile}" into ${fileName}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
'@KTXC': path.resolve(__dirname, '../../core/src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'static',
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: true,
|
||||||
|
lib: {
|
||||||
|
entry: path.resolve(__dirname, 'src/main.ts'),
|
||||||
|
formats: ['es'],
|
||||||
|
fileName: () => 'module.mjs',
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: [
|
||||||
|
'vue',
|
||||||
|
'vue-router',
|
||||||
|
'pinia',
|
||||||
|
'@KTXC',
|
||||||
|
],
|
||||||
|
output: {
|
||||||
|
paths: (id) => {
|
||||||
|
if (id === '@KTXC') return '/js/ktxc.mjs'
|
||||||
|
return id
|
||||||
|
},
|
||||||
|
assetFileNames: (assetInfo) => {
|
||||||
|
if (assetInfo.name?.endsWith('.css')) {
|
||||||
|
return 'media-viewer-[hash].css'
|
||||||
|
}
|
||||||
|
return '[name]-[hash][extname]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user