feat: initial version

Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
2026-06-17 23:13:47 -04:00
parent 390669846b
commit 4e302ea923
21 changed files with 2726 additions and 0 deletions
+35
View File
@@ -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
View File
@@ -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
+12
View File
@@ -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": {}
}
+56
View File
@@ -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',
];
}
}
+1599
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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"
}
}
+10
View File
@@ -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
}
+166
View File
@@ -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>
+143
View File
@@ -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>
+247
View File
@@ -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>
+89
View File
@@ -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>
+41
View File
@@ -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>
+5
View File
@@ -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'
+62
View File
@@ -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
View File
@@ -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,
}
+54
View File
@@ -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
}
+32
View File
@@ -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'
+17
View File
@@ -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"
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.node.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
},
"include": [
"vite.config.ts"
]
}
+62
View File
@@ -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]'
}
}
},
},
})