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