Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
},
"dependencies": {
"@ai-sdk/gateway": "^3.0.55",
"ipx": "^3.1.1",
"@ai-sdk/vue": "^3.0.101",
"@iconify-json/lucide": "^1.2.94",
"@nuxtjs/mdc": "^0.20.1",
Expand Down
1,828 changes: 926 additions & 902 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion src/app/src/components/media/MediaCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { PropType } from 'vue'
import { computed } from 'vue'
import { Image } from '@unpic/vue'
import { isImageFile } from '../../utils/file'
import { getMediaThumbnailUrl } from '../../utils/media'
import { useStudio } from '../../composables/useStudio'
import { StudioItemActionId } from '../../types'
import MediaCardForm from './MediaCardForm.vue'
Expand All @@ -21,7 +22,9 @@ const props = defineProps({
},
})

const imageSrc = computed(() => isImageFile(props.item.fsPath) ? props.item.routePath : null)
const imageSrc = computed(() => {
return isImageFile(props.item.fsPath) ? getMediaThumbnailUrl(props.item.routePath!) : null
})
</script>

<template>
Expand Down
159 changes: 94 additions & 65 deletions src/app/src/components/shared/ModalMediaPicker.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { refDebounced } from '@vueuse/core'
import { useStudio } from '../../composables/useStudio'
import { isImageFile, isVideoFile } from '../../utils/file'
import { Image } from '@unpic/vue'
import type { TreeItem } from '../../types'
import { StudioFeature } from '../../types'
import ImagePreview from './media-picker/ImagePreview.vue'
import VideoPreview from './media-picker/VideoPreview.vue'

const ITEMS_PER_PAGE = 12

const { mediaTree, context } = useStudio()

Expand All @@ -15,6 +19,17 @@ const emit = defineEmits<{
cancel: []
}>()

const searchRef = useTemplateRef<{ $el: HTMLElement }>('searchInput')
const search = ref('')
const page = ref(1)

const debouncedSearch = refDebounced(search, 300)
watch(debouncedSearch, async () => {
page.value = 1
await nextTick()
searchRef.value?.$el.querySelector('input')?.focus()
})

const isValidFileType = (item: TreeItem) => {
if (props.type === 'image') {
return isImageFile(item.fsPath)
Expand All @@ -25,7 +40,7 @@ const isValidFileType = (item: TreeItem) => {
return false
}

const mediaFiles = computed(() => {
const mediaFiles = computed<TreeItem[]>(() => {
const medias: TreeItem[] = []

const collectMedias = (items: TreeItem[]) => {
Expand All @@ -44,13 +59,33 @@ const mediaFiles = computed(() => {
return medias
})

const handleMediaSelect = (media: TreeItem) => {
emit('select', media)
}
const filteredMediaFiles = computed(() => {
const query = debouncedSearch.value.trim().toLowerCase()

if (!query) {
return mediaFiles.value
}

return mediaFiles.value.filter(file => file.fsPath.toLowerCase().includes(query))
})

const paginatedMediaFiles = computed(() => {
const start = (page.value - 1) * ITEMS_PER_PAGE
return filteredMediaFiles.value.slice(start, start + ITEMS_PER_PAGE)
})

const totalPages = computed(() => Math.ceil(filteredMediaFiles.value.length / ITEMS_PER_PAGE))
const paginationTotal = computed(() => Math.max(filteredMediaFiles.value.length, 1))

watch(totalPages, (total) => {
if (page.value > total && total > 0) {
page.value = total
}
})

const handleUpload = async () => {
emit('cancel')
await context.switchFeature(StudioFeature.Media)
emit('cancel')
}

const handleUseExternal = () => {
Expand All @@ -64,81 +99,74 @@ const handleUseExternal = () => {
:open="open"
:title="$t(`studio.mediaPicker.${type}.title`)"
:description="$t(`studio.mediaPicker.${type}.description`)"
:ui="{ content: 'max-w-4xl', body: 'flex flex-col gap-4' }"
@update:open="(value: boolean) => !value && emit('cancel')"
>
<template #body>
<div
v-if="mediaFiles.length === 0"
class="text-center py-4 text-muted"
>
<UIcon
:name="type === 'image' ? 'i-lucide-image-off' : 'i-lucide-video-off'"
class="size-8 mx-auto mb-2"
/>
<p class="text-sm">
{{ $t(`studio.mediaPicker.${type}.notAvailable`) }}
</p>
</div>
<div class="flex h-96 flex-col gap-4">
<div class="flex-1 min-w-0 flex flex-col gap-4">
<UInput
ref="searchInput"
v-model="search"
size="xs"
:placeholder="$t('studio.mediaPicker.searchPlaceholder')"
autofocus
icon="i-lucide-search"
class="w-full max-w-sm"
/>

<div
v-else
class="grid grid-cols-3 gap-4"
>
<button
v-for="media in mediaFiles"
:key="media.fsPath"
class="aspect-square rounded-lg cursor-pointer group relative"
@click="handleMediaSelect(media)"
>
<!-- Image Preview -->
<div
v-if="type === 'image'"
class="w-full h-full overflow-hidden rounded-lg border border-default hover:border-muted hover:ring-1 hover:ring-muted transition-all"
style="background: repeating-linear-gradient(45deg, #d4d4d8 0 12px, #a1a1aa 0 24px), repeating-linear-gradient(-45deg, #a1a1aa 0 12px, #d4d4d8 0 24px); background-blend-mode: overlay; background-size: 24px 24px;"
v-if="mediaFiles.length === 0"
class="text-center py-4 text-muted"
>
<Image
:src="media.routePath || media.fsPath"
width="200"
height="200"
:alt="media.name"
class="w-full h-full object-cover rounded-lg group-hover:scale-105 transition-transform duration-300 ease-out"
<UIcon
:name="type === 'image' ? 'i-lucide-image-off' : 'i-lucide-video-off'"
class="size-8 mx-auto mb-2"
/>
<p class="text-sm">
{{ $t(`studio.mediaPicker.${type}.notAvailable`) }}
</p>
</div>

<!-- Video Preview -->
<div
v-else
class="w-full h-full bg-linear-to-br from-neutral-900 via-neutral-800 to-neutral-900 flex flex-col items-center justify-center relative overflow-hidden rounded-lg"
class="flex min-h-0 flex-1 flex-col gap-6"
>
<!-- Decorative film strip pattern -->
<div class="absolute inset-y-0 left-0 w-3 bg-neutral-950 flex flex-col justify-around py-1">
<div
v-for="i in 6"
:key="i"
class="w-1.5 h-2 bg-neutral-700 mx-auto rounded-sm"
/>
</div>
<div class="absolute inset-y-0 right-0 w-3 bg-neutral-950 flex flex-col justify-around py-1">
<div
v-for="i in 6"
:key="i"
class="w-1.5 h-2 bg-neutral-700 mx-auto rounded-sm"
/>
<div class="grid flex-1 content-start grid-cols-3 gap-4 sm:grid-cols-4 lg:grid-cols-6">
<UTooltip
v-for="media in paginatedMediaFiles"
:key="media.fsPath"
:text="media.fsPath"
:delay-duration="0"
arrow
>
<button
class="aspect-square rounded-lg cursor-pointer group relative"
@click="emit('select', media)"
>
<ImagePreview
v-if="type === 'image'"
:media="media"
/>
<VideoPreview
v-else-if="type === 'video'"
:media="media"
/>
</button>
</UTooltip>
</div>

<div class="size-14 rounded-full bg-white/10 backdrop-blur-sm flex items-center justify-center group-hover:bg-white/20 group-hover:scale-110 transition-all duration-300 shadow-lg">
<UIcon
name="i-lucide-video"
class="size-7 text-white ml-0.5"
<div class="mt-auto flex justify-end">
<UPagination
v-model:page="page"
:total="paginationTotal"
:items-per-page="ITEMS_PER_PAGE"
:sibling-count="1"
show-edges
/>
</div>

<!-- Filename -->
<p class="absolute bottom-0 inset-x-0 text-[10px] text-neutral-300 truncate px-4 py-2 bg-linear-to-t from-black/60 to-transparent text-center font-medium">
{{ media.name }}
</p>
</div>
</button>
</div>
</div>
</template>

Expand All @@ -151,6 +179,7 @@ const handleUseExternal = () => {
>
{{ $t(`studio.mediaPicker.${type}.upload`) }}
</UButton>

<UButton
variant="outline"
icon="i-lucide-link"
Expand Down
54 changes: 54 additions & 0 deletions src/app/src/components/shared/media-picker/ImagePreview.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Image } from '@unpic/vue'
import { getMediaFullUrl, getMediaThumbnailUrl } from '../../../utils/media'
import type { TreeItem } from '../../../types'

const props = defineProps<{
media: TreeItem
/** When true, displays full-size image instead of thumbnail. */
fullSize?: boolean
}>()

const emit = defineEmits<{
loaded: [dimensions: { width: number, height: number }]
}>()

const imageSrc = computed(() => {
const path = props.media.routePath || props.media.fsPath
return props.fullSize ? getMediaFullUrl(path) : getMediaThumbnailUrl(path)
})

function handleImageLoad(event: Event) {
const img = event.target as HTMLImageElement
if (img?.naturalWidth && img?.naturalHeight) {
emit('loaded', { width: img.naturalWidth, height: img.naturalHeight })
}
}
</script>

<template>
<div
:class="[
'relative overflow-hidden rounded-lg border border-default bg-elevated transition-all',
!fullSize && 'hover:border-muted hover:ring-1 hover:ring-muted',
]"
>
<img
v-if="fullSize"
:src="imageSrc"
:alt="media.name"
class="object-contain w-full max-h-96"
@load="handleImageLoad"
>
<Image
v-else
:src="imageSrc"
width="200"
height="200"
layout="fixed"
:alt="media.name"
class="object-cover aspect-square"
/>
</div>
</template>
40 changes: 40 additions & 0 deletions src/app/src/components/shared/media-picker/VideoPreview.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { TreeItem } from '../../../types'

defineProps<{ media: TreeItem }>()
</script>

<template>
<div
class="w-full h-full bg-linear-to-br from-neutral-900 via-neutral-800 to-neutral-900 flex flex-col items-center justify-center relative overflow-hidden rounded-lg"
>
<!-- Decorative film strip pattern -->
<div class="absolute inset-y-0 left-0 w-3 bg-neutral-950 flex flex-col justify-around py-1">
<div
v-for="i in 6"
:key="i"
class="w-1.5 h-2 bg-neutral-700 mx-auto rounded-sm"
/>
</div>

<div class="absolute inset-y-0 right-0 w-3 bg-neutral-950 flex flex-col justify-around py-1">
<div
v-for="i in 6"
:key="i"
class="w-1.5 h-2 bg-neutral-700 mx-auto rounded-sm"
/>
</div>

<div class="size-14 rounded-full bg-white/10 backdrop-blur-sm flex items-center justify-center group-hover:bg-white/20 group-hover:scale-110 transition-all duration-300 shadow-lg">
<UIcon
name="i-lucide-video"
class="size-7 text-white ml-0.5"
/>
</div>

<!-- Filename -->
<p class="absolute bottom-0 inset-x-0 text-[10px] text-neutral-300 truncate px-4 py-2 bg-linear-to-t from-black/60 to-transparent text-center font-medium">
{{ media.name }}
</p>
</div>
</template>
15 changes: 14 additions & 1 deletion src/app/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -326,17 +326,30 @@
"image": {
"title": "Select Image",
"description": "Choose an image from your media library",
"selectionTitle": "Image details",
"selectionDescription": "Review and confirm your selection",
"notAvailable": "No images available in your media library",
"upload": "Upload",
"useExternal": "Use external source"
},
"video": {
"title": "Select Video",
"description": "Choose a video from your media library",
"selectionTitle": "Video details",
"selectionDescription": "Review and confirm your selection",
"notAvailable": "No videos available in your media library",
"upload": "Upload",
"useExternal": "Use external source"
}
},
"name": "Name",
"path": "File path",
"publicPath": "Public path",
"extension": "Extension",
"fileSize": "File size",
"resolution": "Resolution",
"searchPlaceholder": "Search by full path",
"confirm": "Confirm",
"cancelSelection": "Cancel"
},
"form": {
"array": {
Expand Down
Loading
Loading