Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
@@ -0,0 +1,99 @@
<template>
<div class="cloud-toolbar">
<!-- Breadcrumbs -->
<nav class="cloud-breadcrumbs">
<button
v-for="(crumb, i) in breadcrumbs"
:key="crumb.path"
class="cloud-breadcrumb-item"
:class="{ 'cloud-breadcrumb-active': i === breadcrumbs.length - 1 }"
@click="i < breadcrumbs.length - 1 && $emit('navigate', crumb.path)"
>
<svg v-if="i === 0" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<span v-else>{{ crumb.name }}</span>
<svg v-if="i < breadcrumbs.length - 1" class="w-3 h-3 text-white/30 mx-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</nav>
<!-- Actions -->
<div class="flex items-center gap-2">
<!-- View toggle -->
<div class="cloud-view-toggle">
<button
class="cloud-view-toggle-btn"
:class="{ 'cloud-view-toggle-active': viewMode === 'grid' }"
title="Grid view"
@click="$emit('update:viewMode', 'grid')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</button>
<button
class="cloud-view-toggle-btn"
:class="{ 'cloud-view-toggle-active': viewMode === 'list' }"
title="List view"
@click="$emit('update:viewMode', 'list')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
</div>
<button class="glass-button cloud-toolbar-btn" title="Upload file" @click="triggerUpload">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span class="hidden md:inline">Upload</span>
</button>
<button class="glass-button cloud-toolbar-btn" title="Refresh" @click="$emit('refresh')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
<input
ref="fileInput"
type="file"
class="hidden"
multiple
@change="handleFileSelect"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
breadcrumbs: { name: string; path: string }[]
viewMode: 'list' | 'grid'
}>()
const emit = defineEmits<{
navigate: [path: string]
refresh: []
upload: [files: File[]]
'update:viewMode': [mode: 'list' | 'grid']
}>()
const fileInput = ref<HTMLInputElement | null>(null)
function triggerUpload() {
fileInput.value?.click()
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
if (input.files && input.files.length > 0) {
emit('upload', Array.from(input.files))
input.value = ''
}
}
</script>
+137
View File
@@ -0,0 +1,137 @@
<template>
<button
class="cloud-file-item group"
data-controller-container
data-controller-primary
tabindex="0"
@click="handleClick"
>
<!-- Thumbnail / Icon -->
<div class="cloud-file-item-thumb">
<img
v-if="isImage && thumbnailUrl && !imgFailed"
:src="thumbnailUrl"
:alt="item.name"
class="w-full h-full object-cover rounded-[6px] transition-transform duration-300 group-hover:scale-105"
loading="lazy"
@error="imgFailed = true"
/>
<div v-else class="w-full h-full rounded-[6px] flex items-center justify-center bg-white/8">
<svg class="w-5 h-5" :class="iconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(d, i) in iconPaths"
:key="i"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
:d="d"
/>
</svg>
</div>
</div>
<!-- Content -->
<div class="min-w-0 flex-1 py-0.5">
<p class="text-sm font-semibold truncate text-white/90">{{ item.name }}</p>
<p class="text-xs mt-0.5 text-white/40">
<span v-if="!item.isDir">{{ formatSize(item.size) }}</span>
<span v-if="!item.isDir"> · </span>
<span>{{ formatDate(item.modified) }}</span>
</p>
<!-- Type badge -->
<div class="flex items-center gap-1.5 mt-1.5">
<span class="cloud-file-badge" :class="badgeClass">
{{ badgeLabel }}
</span>
<span v-if="item.extension && !item.isDir" class="cloud-file-badge bg-white/8 text-white/50">
.{{ item.extension }}
</span>
</div>
</div>
<!-- Actions -->
<div class="cloud-file-item-actions" @click.stop>
<button
class="cloud-file-action-btn cloud-file-action-share"
title="Share with peers"
@click.stop="$emit('share', item.path, item.name, item.isDir)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</button>
<a
v-if="!item.isDir"
:href="downloadHref"
download
class="cloud-file-action-btn"
title="Download"
@click.stop
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</a>
<button
v-if="!item.isDir"
class="cloud-file-action-btn cloud-file-action-delete"
title="Delete"
@click.stop="$emit('delete', item.path)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<svg v-if="item.isDir" class="w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { FileBrowserItem } from '@/api/filebrowser-client'
import { useCloudStore } from '@/stores/cloud'
import { useFileType, formatSize, formatDate } from '@/composables/useFileType'
const props = defineProps<{
item: FileBrowserItem
}>()
const emit = defineEmits<{
navigate: [path: string]
delete: [path: string]
share: [path: string, name: string, isDir: boolean]
preview: [path: string]
play: [path: string, name: string]
}>()
const cloudStore = useCloudStore()
const imgFailed = ref(false)
const ext = computed(() => props.item.extension)
const isDir = computed(() => props.item.isDir)
const { isImage, isVideo, isAudio, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir)
const thumbnailUrl = computed(() => {
if (!isImage.value || imgFailed.value) return null
return cloudStore.downloadUrl(props.item.path)
})
const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path))
function handleClick() {
if (props.item.isDir) {
emit('navigate', props.item.path)
} else if (isAudio.value) {
// Music goes to the global bottom-bar player, not the lightbox.
emit('play', props.item.path, props.item.name)
} else if (isImage.value || isVideo.value) {
emit('preview', props.item.path)
} else {
// Non-media files open by downloading — a click should always act on the file.
window.location.href = downloadHref.value
}
}
</script>
@@ -0,0 +1,180 @@
<template>
<button
class="cloud-grid-card group"
data-controller-container
tabindex="0"
@click="handleClick"
>
<!-- Cover / Thumbnail area -->
<div class="cloud-grid-card-cover">
<!-- Image thumbnail -->
<img
v-if="isImage && thumbnailUrl && !imgFailed"
:src="thumbnailUrl"
:alt="item.name"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="imgFailed = true"
/>
<!-- Video thumbnail (try to show, fallback to icon) -->
<img
v-else-if="isVideo && thumbnailUrl && !imgFailed"
:src="thumbnailUrl"
:alt="item.name"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="imgFailed = true"
/>
<!-- Icon fallback -->
<div v-else class="w-full h-full flex items-center justify-center" :class="coverBg">
<svg class="w-10 h-10" :class="iconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(d, i) in iconPaths"
:key="i"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
:d="d"
/>
</svg>
</div>
<!-- Gradient overlay -->
<div class="cloud-grid-card-gradient"></div>
<!-- Play button overlay for audio/video -->
<div
v-if="isAudio || isVideo"
class="cloud-grid-card-play"
:class="{ 'cloud-grid-card-play-active': isCurrentlyPlaying }"
@click.stop="isVideo ? emit('preview', item.path) : emit('play', item.path, item.name)"
>
<span class="cloud-grid-card-play-btn">
<svg v-if="!isCurrentlyPlaying" class="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
<svg v-else class="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
</span>
</div>
<!-- Info overlay at bottom -->
<div class="cloud-grid-card-info">
<p class="text-xs font-semibold text-white/90 leading-tight truncate">
{{ item.name }}
</p>
<div class="flex items-center gap-1 mt-0.5">
<span v-if="!item.isDir" class="text-xs text-white/40">{{ formatSize(item.size) }}</span>
<span v-if="!item.isDir" class="text-xs text-white/40">·</span>
<span class="text-xs text-white/40">{{ formatDate(item.modified) }}</span>
</div>
</div>
<!-- Badge at top-right -->
<div class="cloud-grid-card-badges">
<span class="cloud-grid-card-badge" :class="badgeClass">
{{ badgeLabel }}
</span>
</div>
<!-- Actions overlay at top-left (visible on hover) -->
<div class="cloud-grid-card-actions" @click.stop>
<button
class="cloud-file-action-btn cloud-file-action-share"
title="Share with peers"
@click.stop="emit('share', item.path, item.name, item.isDir)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</button>
<a
v-if="!item.isDir"
:href="downloadHref"
download
class="cloud-file-action-btn"
title="Download"
@click.stop
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</a>
<button
v-if="!item.isDir"
class="cloud-file-action-btn cloud-file-action-delete"
title="Delete"
@click.stop="emit('delete', item.path)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</button>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { FileBrowserItem } from '@/api/filebrowser-client'
import { useCloudStore } from '@/stores/cloud'
import { useFileType, formatSize, formatDate } from '@/composables/useFileType'
import { useAudioPlayer } from '@/composables/useAudioPlayer'
const props = defineProps<{
item: FileBrowserItem
}>()
const emit = defineEmits<{
navigate: [path: string]
delete: [path: string]
play: [path: string, name: string]
share: [path: string, name: string, isDir: boolean]
preview: [path: string]
}>()
const cloudStore = useCloudStore()
const imgFailed = ref(false)
const ext = computed(() => props.item.extension)
const isDir = computed(() => props.item.isDir)
const { category, isImage, isAudio, isVideo, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir)
const thumbnailUrl = computed(() => {
if (imgFailed.value) return null
if (isImage.value || isVideo.value) return cloudStore.downloadUrl(props.item.path)
return null
})
const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path))
const { playing: audioPlaying, currentSrc } = useAudioPlayer()
const isCurrentlyPlaying = computed(() => audioPlaying.value && currentSrc.value === downloadHref.value)
// Uniform card cover ratio across every file type so folders, images, videos
// and documents all render at the same height in the grid (previously images/
// videos were square while folders were 4/3, giving a ragged, mismatched grid).
// Aspect is now driven entirely by .cloud-grid-card-cover CSS (4/3 desktop,
// square on mobile) so the ratio is deterministic regardless of Tailwind layer
// ordering.
const coverBg = computed(() => {
if (props.item.isDir) return 'bg-amber-500/10'
if (isAudio.value) return 'bg-orange-500/10'
if (isVideo.value) return 'bg-purple-500/10'
if (isImage.value) return 'bg-blue-500/10'
if (category.value === 'document') return 'bg-green-500/10'
if (category.value === 'spreadsheet') return 'bg-emerald-500/10'
if (category.value === 'archive') return 'bg-yellow-500/10'
return 'bg-white/5'
})
function handleClick() {
if (props.item.isDir) {
emit('navigate', props.item.path)
} else if (isImage.value || isVideo.value) {
emit('preview', props.item.path)
}
}
</script>
@@ -0,0 +1,84 @@
<template>
<div class="flex-1 min-h-0 overflow-y-auto">
<!-- Loading skeleton -->
<div v-if="loading" :class="viewMode === 'grid' ? 'cloud-card-grid' : 'cloud-file-list'">
<div
v-for="i in 6"
:key="i"
:class="viewMode === 'grid' ? 'cloud-grid-card-skeleton' : 'cloud-file-item cloud-file-item-skeleton'"
>
<template v-if="viewMode === 'grid'">
<div class="aspect-square rounded-[10px] bg-white/8 animate-pulse"></div>
</template>
<template v-else>
<div class="cloud-file-item-thumb">
<div class="w-full h-full rounded-[6px] bg-white/8 animate-pulse"></div>
</div>
<div class="min-w-0 flex-1 py-0.5">
<div class="h-4 w-32 rounded bg-white/8 animate-pulse mb-1.5"></div>
<div class="h-3 w-20 rounded bg-white/5 animate-pulse"></div>
</div>
</template>
</div>
</div>
<!-- Empty state -->
<div v-else-if="items.length === 0" class="flex flex-col items-center justify-center py-16 text-center">
<svg class="w-16 h-16 text-white/10 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<p class="text-white/50 text-sm">This folder is empty</p>
<p class="text-white/30 text-xs mt-1">Upload files to get started</p>
</div>
<!-- Grid view -->
<div v-else-if="viewMode === 'grid'" class="cloud-card-grid">
<FileCardGrid
v-for="item in items"
:key="item.path"
:item="item"
@navigate="$emit('navigate', $event)"
@delete="$emit('delete', $event)"
@play="(path, name) => $emit('play', path, name)"
@share="(path, name, isDir) => $emit('share', path, name, isDir)"
@preview="$emit('preview', $event)"
/>
</div>
<!-- List view -->
<div v-else class="cloud-file-list">
<FileCard
v-for="item in items"
:key="item.path"
:item="item"
@navigate="$emit('navigate', $event)"
@delete="$emit('delete', $event)"
@play="(path, name) => $emit('play', path, name)"
@share="(path, name, isDir) => $emit('share', path, name, isDir)"
@preview="$emit('preview', $event)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { FileBrowserItem } from '@/api/filebrowser-client'
import FileCard from './FileCard.vue'
import FileCardGrid from './FileCardGrid.vue'
withDefaults(defineProps<{
items: FileBrowserItem[]
loading: boolean
viewMode?: 'list' | 'grid'
}>(), {
viewMode: 'grid',
})
defineEmits<{
navigate: [path: string]
delete: [path: string]
play: [path: string, name: string]
share: [path: string, name: string, isDir: boolean]
preview: [path: string]
}>()
</script>
@@ -0,0 +1,528 @@
<template>
<Teleport to="body">
<Transition name="lightbox-fade">
<div
v-if="show"
class="lightbox-backdrop"
:class="{ 'lightbox-pip-handoff': pipHandoff }"
@click.self="close"
@keydown="onKeydown"
tabindex="0"
ref="backdropEl"
>
<!-- Top bar -->
<div class="lightbox-topbar">
<div class="flex items-center gap-3 min-w-0">
<span v-if="mediaItems.length > 1" class="text-sm text-white/50">
{{ currentIndex + 1 }} / {{ mediaItems.length }}
</span>
<p class="text-sm text-white/80 truncate">{{ currentItem?.name }}</p>
</div>
<div class="flex items-center gap-1">
<button
v-if="pipAvailable && currentItem && isVideoFile(currentItem)"
class="lightbox-btn"
title="Picture-in-picture"
@click.stop="togglePip(videoEl)"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="5" width="18" height="14" rx="2" stroke-width="2" />
<rect x="12" y="12" width="7" height="5" rx="1" stroke-width="2" />
</svg>
</button>
<button class="lightbox-btn" @click="close">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Navigation arrows -->
<button
v-if="mediaItems.length > 1"
class="lightbox-nav lightbox-nav-prev"
@click.stop="prev"
>
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
v-if="mediaItems.length > 1"
class="lightbox-nav lightbox-nav-next"
@click.stop="next"
>
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<!-- Media content -->
<div class="lightbox-content" @click.stop>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center h-full">
<div class="w-12 h-12 border-3 border-white/10 border-t-white/70 rounded-full animate-spin"></div>
</div>
<!-- Image -->
<img
v-else-if="currentItem && currentUrl && isImageFile(currentItem)"
:src="currentUrl"
:alt="currentItem.name"
class="lightbox-media-img"
@error="onMediaError"
/>
<!-- Video -->
<video
v-else-if="currentItem && currentUrl && isVideoFile(currentItem)"
ref="videoEl"
:src="currentUrl"
:key="currentUrl"
class="lightbox-media-video"
controls
autoplay
@dblclick="toggleFullscreen"
@error="onMediaError"
@enterpictureinpicture="onEnterPip"
@leavepictureinpicture="onLeavePip"
/>
<!-- Audio -->
<div
v-else-if="currentItem && currentUrl && isAudioFile(currentItem)"
class="lightbox-audio-container"
>
<div class="lightbox-audio-artwork">
<svg class="w-20 h-20 text-orange-400/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
</svg>
</div>
<p class="text-white/70 text-sm mt-4 truncate max-w-xs">{{ currentItem.name }}</p>
<audio
:src="currentUrl"
:key="currentUrl"
controls
autoplay
class="lightbox-audio-player"
@error="onMediaError"
/>
</div>
<!-- Error -->
<div v-else-if="mediaError" class="flex flex-col items-center justify-center gap-3">
<svg class="w-12 h-12 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p class="text-white/40 text-sm">Failed to load media</p>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import type { FileBrowserItem } from '@/api/filebrowser-client'
import { getFileCategory } from '@/composables/useFileType'
import { isPipSupported, togglePip } from '@/utils/pip'
import { usePipSession } from '@/composables/usePipSession'
const props = defineProps<{
items: FileBrowserItem[]
startIndex: number
show: boolean
fetchBlobUrl: (path: string) => Promise<string>
streamUrl?: (path: string) => Promise<string>
}>()
const emit = defineEmits<{
close: []
}>()
const currentIndex = ref(0)
const loading = ref(false)
const mediaError = ref(false)
const currentUrl = ref<string | null>(null)
const backdropEl = ref<HTMLElement | null>(null)
const videoEl = ref<HTMLVideoElement | null>(null)
const pipAvailable = isPipSupported()
const pipSession = usePipSession()
const pipHandoff = ref(false)
// Bounded fallback for the transitionend-driven close below — covers a
// browser that skips the transition entirely (including the reduced-motion
// path, where the CSS transition duration is zero and transitionend never
// fires).
const PIP_HANDOFF_FALLBACK_MS = 350
let handoffTimer: ReturnType<typeof setTimeout> | null = null
const urlCache = new Map<string, string>()
const mediaItems = computed(() =>
props.items.filter(item => {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
const cat = getFileCategory(ext, item.isDir)
return cat === 'image' || cat === 'video' || cat === 'audio'
})
)
const currentItem = computed(() => mediaItems.value[currentIndex.value] ?? null)
function isImageFile(item: FileBrowserItem): boolean {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
return getFileCategory(ext, false) === 'image'
}
function isVideoFile(item: FileBrowserItem): boolean {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
return getFileCategory(ext, false) === 'video'
}
function isAudioFile(item: FileBrowserItem): boolean {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
return getFileCategory(ext, false) === 'audio'
}
async function loadMedia(item: FileBrowserItem) {
loading.value = true
mediaError.value = false
currentUrl.value = null
try {
const cached = urlCache.get(item.path)
if (cached) {
currentUrl.value = cached
} else {
// Use streaming URL for video/audio (avoids downloading entire file into blob)
// Use blob URL for images (needed for rendering)
const isStreamable = isVideoFile(item) || isAudioFile(item)
if (isStreamable && props.streamUrl) {
const url = await props.streamUrl(item.path)
urlCache.set(item.path, url)
currentUrl.value = url
} else {
const url = await props.fetchBlobUrl(item.path)
urlCache.set(item.path, url)
currentUrl.value = url
}
}
} catch {
mediaError.value = true
} finally {
loading.value = false
}
}
function preloadAdjacent() {
const items = mediaItems.value
if (items.length <= 1) return
const prevIdx = (currentIndex.value - 1 + items.length) % items.length
const nextIdx = (currentIndex.value + 1) % items.length
for (const idx of [prevIdx, nextIdx]) {
const item = items[idx]
if (item && !urlCache.has(item.path) && isImageFile(item)) {
props.fetchBlobUrl(item.path).then(url => {
urlCache.set(item.path, url)
}).catch(() => {})
}
}
}
function prev() {
const len = mediaItems.value.length
if (len <= 1) return
currentIndex.value = (currentIndex.value - 1 + len) % len
}
function next() {
const len = mediaItems.value.length
if (len <= 1) return
currentIndex.value = (currentIndex.value + 1) % len
}
function close() {
emit('close')
}
function finishPipHandoff() {
backdropEl.value?.removeEventListener('transitionend', finishPipHandoff)
if (handoffTimer) {
clearTimeout(handoffTimer)
handoffTimer = null
}
emit('close')
}
// Listens for the enterpictureinpicture event itself, rather than inferring
// from the button click, so PiP entered by any route (the browser's own
// control, a keyboard shortcut) behaves the same. Order matters here and
// must stay exactly this: adopt first, animate second, emit last — adopting
// first is what makes the element survive the unmount the emit triggers.
function onEnterPip() {
const video = videoEl.value
if (!video) return
pipSession.adopt(video)
pipHandoff.value = true
backdropEl.value?.addEventListener('transitionend', finishPipHandoff, { once: true })
handoffTimer = setTimeout(finishPipHandoff, PIP_HANDOFF_FALLBACK_MS)
}
// The session's own leavepictureinpicture listener (attached in
// usePipSession at adopt time) is the primary release path, since the
// lightbox has normally already unmounted by the time PiP is exited. This
// handler only covers the case where the lightbox is somehow still
// mounted; release() is idempotent so calling it twice is harmless.
function onLeavePip() {
pipSession.release()
}
function toggleFullscreen() {
const el = videoEl.value
if (!el) return
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
el.requestFullscreen().catch(() => {})
}
}
function onMediaError() {
mediaError.value = true
loading.value = false
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { e.preventDefault(); close() }
else if (e.key === 'ArrowLeft') { e.preventDefault(); prev() }
else if (e.key === 'ArrowRight') { e.preventDefault(); next() }
}
watch(currentItem, (item) => {
if (item) {
loadMedia(item)
preloadAdjacent()
}
})
watch(() => props.show, async (visible) => {
if (visible) {
pipHandoff.value = false
currentIndex.value = props.startIndex
const item = mediaItems.value[props.startIndex]
if (item) {
await loadMedia(item)
preloadAdjacent()
}
await nextTick()
backdropEl.value?.focus()
}
}, { immediate: true })
onUnmounted(() => {
for (const url of urlCache.values()) {
URL.revokeObjectURL(url)
}
urlCache.clear()
})
</script>
<style scoped>
.lightbox-backdrop {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.92);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
outline: none;
}
.lightbox-topbar {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background: linear-gradient(to bottom, rgba(0,0,0,0.6) 0%, transparent 100%);
z-index: 10;
}
.lightbox-btn {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.2s;
cursor: pointer;
}
.lightbox-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: white;
}
.lightbox-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 3rem;
height: 3rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.06);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
transition: all 0.2s;
cursor: pointer;
z-index: 10;
}
.lightbox-nav:hover {
background: rgba(255, 255, 255, 0.12);
color: white;
border-color: rgba(255, 255, 255, 0.15);
}
.lightbox-nav-prev { left: 1rem; }
.lightbox-nav-next { right: 1rem; }
.lightbox-content {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 3.5rem 1rem;
}
.lightbox-media-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 0.5rem;
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.5);
}
.lightbox-media-video {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 0;
background: black;
cursor: pointer;
}
.lightbox-audio-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem;
}
.lightbox-audio-artwork {
width: 12rem;
height: 12rem;
border-radius: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(251, 146, 60, 0.12) 0%, rgba(251, 146, 60, 0.04) 100%);
border: 1px solid rgba(251, 146, 60, 0.15);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
}
.lightbox-audio-player {
width: 100%;
max-width: 24rem;
margin-top: 1rem;
border-radius: 2rem;
filter: invert(1) hue-rotate(180deg) brightness(0.85) contrast(0.9);
}
/* Transitions */
.lightbox-fade-enter-active,
.lightbox-fade-leave-active {
transition: opacity 0.25s ease;
}
.lightbox-fade-enter-from,
.lightbox-fade-leave-to {
opacity: 0;
}
/* Picture-in-picture handoff (UIFIX-05): reads as the video moving into the
PiP window rather than a dismissal. The backdrop's blur/opacity fall away
while the content scales down and drifts toward the bottom-right corner
most browsers default the PiP window to — a best-effort convention, since
the actual corner is browser- and user-controlled. Applied only on the
PiP path; a normal close (button or Escape) never gets this class. */
.lightbox-pip-handoff {
transition: opacity 0.3s ease, backdrop-filter 0.3s ease;
opacity: 0;
backdrop-filter: blur(0px);
-webkit-backdrop-filter: blur(0px);
}
.lightbox-pip-handoff .lightbox-topbar,
.lightbox-pip-handoff .lightbox-nav {
transition: opacity 0.3s ease;
opacity: 0;
}
.lightbox-pip-handoff .lightbox-content {
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.3s ease;
transform: scale(0.4) translate(40vw, 40vh);
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.lightbox-pip-handoff,
.lightbox-pip-handoff .lightbox-topbar,
.lightbox-pip-handoff .lightbox-nav,
.lightbox-pip-handoff .lightbox-content {
transition: none;
}
}
/* Mobile */
@media (max-width: 768px) {
.lightbox-content { padding: 3rem 0; }
.lightbox-nav { width: 2.5rem; height: 2.5rem; }
.lightbox-nav-prev { left: 0.5rem; }
.lightbox-nav-next { right: 0.5rem; }
.lightbox-audio-artwork { width: 8rem; height: 8rem; }
.lightbox-media-video { border-radius: 0; }
/* The close button used to sit in the top bar, which lands under the
status bar / notch safe area on most phones and is awkward to reach.
Detach it from the top bar and pin it bottom-center, under the media,
for mobile only — desktop keeps it in the top bar. */
.lightbox-topbar { padding-right: 1rem; }
.lightbox-topbar .lightbox-btn {
position: fixed;
top: auto;
bottom: calc(env(safe-area-inset-bottom, 0px) + 1rem);
left: 50%;
right: auto;
transform: translateX(-50%);
z-index: 20;
}
}
</style>
@@ -0,0 +1,447 @@
<template>
<Teleport to="body">
<div class="share-modal-backdrop" @click.self="$emit('close')">
<div class="share-modal glass-card">
<!-- Header -->
<div class="flex items-center justify-between mb-5">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg bg-orange-500/15 flex items-center justify-center">
<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</div>
<div>
<h3 class="text-base font-semibold text-white">Share with Peers</h3>
<p class="text-xs text-white/50 truncate max-w-[200px]">{{ filename }}</p>
</div>
</div>
<button class="share-modal-close" @click="$emit('close')">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Share Toggle -->
<div class="share-modal-row">
<div class="flex-1">
<p class="text-sm font-medium text-white/90">Share this {{ isDir ? 'folder' : 'file' }}</p>
<p class="text-xs text-white/50 mt-0.5">Make visible to connected peers</p>
</div>
<ToggleSwitch v-model="shared" />
</div>
<!-- Access Type (only when shared) -->
<div v-if="shared" class="mt-4 space-y-3">
<p class="text-xs font-medium text-white/60 uppercase tracking-wider">Access Type</p>
<div class="share-access-options">
<button
class="share-access-option"
:class="{ 'share-access-option-active': accessType === 'free' }"
@click="accessType = 'free'"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm font-medium">Free</span>
<span class="text-xs text-white/40">Open access</span>
</button>
<button
class="share-access-option"
:class="{ 'share-access-option-active': accessType === 'peers_only' }"
@click="accessType = 'peers_only'"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<span class="text-sm font-medium">Peers Only</span>
<span class="text-xs text-white/40">Authenticated</span>
</button>
<button
class="share-access-option"
:class="{ 'share-access-option-active': accessType === 'paid' }"
@click="accessType = 'paid'"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="text-sm font-medium">Paid</span>
<span class="text-xs text-white/40">Earn sats</span>
</button>
</div>
<!-- Price Input (only for paid) -->
<div v-if="accessType === 'paid'" class="share-price-input-wrap">
<div class="share-price-icon">
<svg class="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<input
v-model.number="priceSats"
type="number"
min="1"
max="1000000"
placeholder="Price in sats"
class="share-price-input"
/>
<span class="share-price-unit">sats</span>
</div>
<!-- Accepted payment methods (only for paid) — gated on what this
node can actually receive; unavailable rails are disabled with
an ⓘ that explains how to enable them. -->
<div v-if="accessType === 'paid'" class="mt-3">
<p class="text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Payments you accept</p>
<div class="space-y-2">
<div v-for="m in PAY_METHODS" :key="m.key" class="share-modal-row">
<div class="flex-1 flex items-center gap-2 min-w-0">
<p class="text-sm text-white/90">{{ m.label }}</p>
<span v-if="capability[m.key] === undefined" class="text-[10px] text-white/40">checking…</span>
<button
v-else-if="!capability[m.key]"
class="w-4 h-4 rounded-full bg-white/10 text-white/60 hover:text-white text-[10px] leading-4 text-center shrink-0"
title="Why is this unavailable?"
@click="adviceFor = m.key"
>i</button>
</div>
<ToggleSwitch
:model-value="acceptedSet.has(m.key)"
:disabled="!capability[m.key]"
:aria-label="`Accept ${m.label}`"
@update:model-value="toggleMethod(m.key, $event)"
/>
</div>
</div>
<p v-if="acceptedSet.size === 0" class="text-xs text-red-400 mt-2">
Pick at least one payment method buyers can use.
</p>
</div>
</div>
<!-- Advice modal for an unavailable payment method -->
<div v-if="adviceFor" class="mt-4 p-3 rounded-lg bg-white/5 border border-white/10">
<div class="flex items-center justify-between mb-1">
<p class="text-sm font-medium text-white">{{ PAY_METHODS.find(m => m.key === adviceFor)?.label }} isn't ready on this node</p>
<button class="text-white/50 hover:text-white text-xs" @click="adviceFor = null">Dismiss</button>
</div>
<ul class="text-xs text-white/60 list-disc pl-4 space-y-1">
<li v-for="line in adviceLines[adviceFor] || []" :key="line">{{ line }}</li>
</ul>
</div>
<!-- Status messages -->
<div v-if="saving" class="share-modal-status mt-4">
<div class="w-4 h-4 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
<span class="text-sm text-white/60">Saving...</span>
</div>
<div v-if="errorMsg" class="share-modal-error mt-4">
<span class="text-sm text-red-400">{{ errorMsg }}</span>
</div>
<div v-if="successMsg" class="share-modal-success mt-4">
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<span class="text-sm text-green-400">{{ successMsg }}</span>
</div>
<!-- Action -->
<div class="flex justify-end gap-3 mt-5">
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="$emit('close')">Cancel</button>
<button
class="glass-button px-5 py-2 rounded-lg text-sm font-medium share-modal-save"
:disabled="saving || (shared && accessType === 'paid' && (!priceSats || priceSats < 1 || acceptedSet.size === 0))"
@click="save"
>
{{ shared ? 'Share' : 'Stop Sharing' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script lang="ts">
/**
* Extension → MIME map used when sharing a file that isn't already in the
* catalog (`save()` below, `content.add`). Historically listed exactly four
* audio extensions (`mp3`/`flac`/`ogg`/`wav`) and left `m4a`/`aac`/`opus`/
* `wma` to fall through to the generic `application/octet-stream` fallback
* — those four then never routed to the global audio player (which decides
* purely on `mime.startsWith('audio/')`, see `usePaidItemViewer.ts`) and
* auto-filed to Documents instead of Music (`content.rs`'s paid-download
* auto-filing, same `starts_with("audio/")` check) — 13-CONTEXT.md's
* landmine, T-13-72. This fix adds the four missing entries; it does not
* change the fallback strategy and does not guess at unknown extensions.
*
* Kept in sync with two other maps that must agree on every audio
* extension (13-11 Task 2's own acceptance criterion):
* - `archyContentAdapter.ts`'s `classifyByMime`/`AUDIO_EXT_FALLBACK` (13-06)
* - `content.rs`'s auto-filing check (`mime_type.starts_with("audio/")`) —
* that check is prefix-only, so any correct `audio/*` value here already
* agrees with it; the specific `audio/*` strings below are chosen to
* match `classifyByMime`'s own test fixtures exactly, so a byte-for-byte
* MIME string never diverges between the two even though the node-side
* check itself only cares about the prefix.
*
* Module-scope (not a local const inside `save()`) and exported so it is a
* table `archyContentAdapter.test.ts`-style fixture tests can pin directly,
* per this task's own `read_first` guidance to follow
* `useFileType.test.ts`'s fixture-table convention.
*/
export const SHARE_MIME_MAP: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
mp4: 'video/mp4',
webm: 'video/webm',
mkv: 'video/x-matroska',
mp3: 'audio/mpeg',
flac: 'audio/flac',
ogg: 'audio/ogg',
wav: 'audio/wav',
m4a: 'audio/mp4',
aac: 'audio/aac',
opus: 'audio/opus',
wma: 'audio/x-ms-wma',
pdf: 'application/pdf',
zip: 'application/zip',
txt: 'text/plain',
}
</script>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
const props = defineProps<{
filename: string
filepath: string
isDir: boolean
existingItemId?: string
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const shared = ref(false)
const accessType = ref<'free' | 'peers_only' | 'paid'>('free')
const priceSats = ref<number>(100)
const saving = ref(false)
const errorMsg = ref<string | null>(null)
const successMsg = ref<string | null>(null)
// --- Accepted payment methods, gated on what this node can actually receive ---
const PAY_METHODS = [
{ key: 'lightning', label: 'Lightning' },
{ key: 'onchain', label: 'On-chain' },
{ key: 'ecash', label: 'Cashu ecash' },
{ key: 'fedimint', label: 'Fedimint' },
] as const
type PayMethod = (typeof PAY_METHODS)[number]['key']
// undefined = probe in flight; then true/false per method.
const capability = ref<Partial<Record<PayMethod, boolean>>>({})
const adviceLines = ref<Partial<Record<PayMethod, string[]>>>({})
const acceptedSet = ref<Set<PayMethod>>(new Set())
const adviceFor = ref<PayMethod | null>(null)
// Only default-select capable methods when the item had no saved list.
let acceptedLoadedFromItem = false
function toggleMethod(key: PayMethod, on: boolean) {
const next = new Set(acceptedSet.value)
if (on) next.add(key)
else next.delete(key)
acceptedSet.value = next
}
/** Probe the node's rails and build advice for the unavailable ones. */
async function probeCapabilities() {
// Lightning + on-chain both live on LND.
try {
const info = await rpcClient.call<{ num_active_channels?: number; synced_to_chain?: boolean }>({
method: 'lnd.getinfo', timeout: 8000,
})
capability.value.onchain = true
const channels = info?.num_active_channels ?? 0
capability.value.lightning = channels > 0
if (channels === 0) {
adviceLines.value.lightning = [
'Your Lightning node is running but has no active channel — buyers cannot pay you over Lightning yet.',
'Open a channel from Wallet → Lightning Channels (funds on your on-chain balance can back it).',
'Once the channel is active, come back and enable Lightning here.',
]
}
} catch {
capability.value.lightning = false
capability.value.onchain = false
adviceLines.value.lightning = [
'The Lightning (LND) app isn\'t running on this node.',
'Install/start Lightning from the App Store, let it sync, then open a channel.',
]
adviceLines.value.onchain = [
'On-chain receiving uses the Lightning (LND) app\'s wallet, which isn\'t running.',
'Install/start Lightning from the App Store — no channel needed for on-chain.',
]
}
try {
await rpcClient.call({ method: 'wallet.ecash-balance', timeout: 8000 })
capability.value.ecash = true
} catch {
capability.value.ecash = false
adviceLines.value.ecash = [
'The Cashu ecash wallet isn\'t set up on this node.',
'Open Wallet → Ecash to connect a mint, then enable Cashu here.',
]
}
try {
await rpcClient.call({ method: 'wallet.fedimint-balance', timeout: 8000 })
capability.value.fedimint = true
} catch {
capability.value.fedimint = false
adviceLines.value.fedimint = [
'This node hasn\'t joined a Fedimint federation.',
'Install the Fedimint app and join (or create) a federation, then enable it here.',
]
}
// Defaults: everything the node can receive — unless the item already
// carried an explicit list. Never auto-enable an incapable rail.
if (!acceptedLoadedFromItem) {
acceptedSet.value = new Set(PAY_METHODS.filter((m) => capability.value[m.key]).map((m) => m.key))
} else {
acceptedSet.value = new Set([...acceptedSet.value].filter((k) => capability.value[k]))
}
}
// If we have an existing item, load its state
/** Catalog entries store the slash-stripped path; props carry a leading
* slash (filepath) or just the basename (filename). Normalize both sides —
* the old exact compare never matched, so every re-share created a brand
* new priced entry and buyers could pay twice for one file (2026-07-22). */
function matchesThisFile(catalogFilename: string): boolean {
const strip = (v: string) => v.replace(/^\/+/, '')
return (
strip(catalogFilename) === strip(props.filepath || '') ||
strip(catalogFilename) === strip(props.filename || '')
)
}
onMounted(async () => {
try {
const res = await rpcClient.call<{ items: Array<{
id: string
filename: string
access: { free?: unknown; peersonly?: unknown; paid?: { price_sats: number; accepted?: string[] } } | string
availability: string | { allpeers?: unknown; nobody?: unknown }
}> }>({ method: 'content.list-mine' })
const match = res.items.find(
(i) => matchesThisFile(i.filename)
)
if (match) {
shared.value = true
const access = match.access
if (typeof access === 'string') {
if (access === 'free') accessType.value = 'free'
else if (access === 'peersonly') accessType.value = 'peers_only'
} else if (access && typeof access === 'object') {
if ('paid' in access && access.paid) {
accessType.value = 'paid'
priceSats.value = access.paid.price_sats || 100
if (Array.isArray(access.paid.accepted) && access.paid.accepted.length) {
acceptedLoadedFromItem = true
acceptedSet.value = new Set(
access.paid.accepted.filter((m): m is PayMethod =>
PAY_METHODS.some((p) => p.key === m),
),
)
}
} else if ('peersonly' in access) {
accessType.value = 'peers_only'
}
}
}
} catch (e) {
if (import.meta.env.DEV) console.warn('Not shared yet, defaults are fine', e)
}
void probeCapabilities()
})
async function save() {
saving.value = true
errorMsg.value = null
successMsg.value = null
try {
if (!shared.value) {
// Find and remove from catalog
const res = await rpcClient.call<{ items: Array<{ id: string; filename: string }> }>({
method: 'content.list-mine',
})
const match = res.items.find(
(i) => matchesThisFile(i.filename)
)
if (match) {
await rpcClient.call({ method: 'content.remove', params: { id: match.id } })
}
successMsg.value = 'Sharing disabled'
} else {
// Check if already in catalog
const res = await rpcClient.call<{ items: Array<{ id: string; filename: string }> }>({
method: 'content.list-mine',
})
let itemId = res.items.find(
(i) => matchesThisFile(i.filename)
)?.id
// Add if not in catalog
if (!itemId) {
const ext = props.filename.split('.').pop()?.toLowerCase() || ''
const addRes = await rpcClient.call<{ item: { id: string } }>({
method: 'content.add',
params: {
filename: (props.filepath || props.filename).replace(/^\/+/, ''),
mime_type: SHARE_MIME_MAP[ext] || 'application/octet-stream',
description: '',
},
})
itemId = addRes.item.id
}
// Set pricing
const pricingParams: Record<string, unknown> = { id: itemId, access: accessType.value }
if (accessType.value === 'paid') {
pricingParams.price_sats = priceSats.value
pricingParams.accepted_methods = [...acceptedSet.value]
}
await rpcClient.call({ method: 'content.set-pricing', params: pricingParams })
// Set availability to all peers
await rpcClient.call({
method: 'content.set-availability',
params: { id: itemId, availability: 'all_peers' },
})
const label =
accessType.value === 'paid'
? `Shared for ${priceSats.value} sats`
: accessType.value === 'peers_only'
? 'Shared with peers'
: 'Shared (free)'
successMsg.value = label
}
setTimeout(() => emit('saved'), 800)
} catch (e) {
errorMsg.value = e instanceof Error ? e.message : 'Failed to update sharing'
} finally {
saving.value = false
}
}
</script>
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest'
import { SHARE_MIME_MAP } from '../ShareModal.vue'
// 13-11 Task 2: ShareModal.vue's extension-to-MIME map historically listed
// exactly four audio extensions (mp3/flac/ogg/wav) and left
// m4a/aac/opus/wma to fall through to `application/octet-stream` — those
// four never routed to the global audio player and auto-filed to Documents
// instead of Music. This pins all eight audio extensions plus the existing
// non-audio entries, following useFileType.test.ts's fixture-table
// convention.
describe('ShareModal SHARE_MIME_MAP', () => {
it.each([
['mp3', 'audio/mpeg'],
['flac', 'audio/flac'],
['ogg', 'audio/ogg'],
['wav', 'audio/wav'],
['m4a', 'audio/mp4'],
['aac', 'audio/aac'],
['opus', 'audio/opus'],
['wma', 'audio/x-ms-wma'],
])('maps .%s to the audio MIME %s', (ext, expected) => {
expect(SHARE_MIME_MAP[ext]).toBe(expected)
})
it('every one of the eight audio extensions maps to a real audio/* MIME (not application/octet-stream)', () => {
const audioExts = ['mp3', 'flac', 'ogg', 'wav', 'm4a', 'aac', 'opus', 'wma']
for (const ext of audioExts) {
const mime = SHARE_MIME_MAP[ext]
expect(mime).toBeDefined()
expect(mime!.startsWith('audio/')).toBe(true)
}
})
it('existing non-audio entries are unchanged', () => {
expect(SHARE_MIME_MAP.jpg).toBe('image/jpeg')
expect(SHARE_MIME_MAP.mp4).toBe('video/mp4')
expect(SHARE_MIME_MAP.pdf).toBe('application/pdf')
expect(SHARE_MIME_MAP.zip).toBe('application/zip')
expect(SHARE_MIME_MAP.txt).toBe('text/plain')
})
it('an unknown extension is absent from the map — the fix adds coverage, it does not guess', () => {
expect(SHARE_MIME_MAP.xyz123).toBeUndefined()
})
it('agrees with archyContentAdapter.ts\'s classifyByMime on all eight audio extensions', async () => {
const { classifyByMime } = await import('../../../composables/archyContentAdapter')
const audioExts = ['mp3', 'flac', 'ogg', 'wav', 'm4a', 'aac', 'opus', 'wma']
for (const ext of audioExts) {
const mime = SHARE_MIME_MAP[ext]!
expect(classifyByMime({ mime_type: mime, filename: `track.${ext}` })).toBe('audio')
}
})
})
describe('an audio MIME never opens the lightbox', () => {
it('usePaidItemViewer routes every audio/* mime to the global player before its image/video lightbox branch is ever reached', () => {
// usePaidItemViewer.ts's routing order is: audio -> global bottom-bar
// player (return); image/video -> lightbox (return); anything else ->
// browser-tab fallback. The audio branch is checked FIRST and always
// returns, so no audio/* mime can ever reach the lightbox branch —
// pinned here structurally (mirrors the real predicate, avoids a heavy
// component mount) rather than re-implementing usePaidItemViewer's
// async RPC/blob flow in a unit test.
const audioExts = ['mp3', 'flac', 'ogg', 'wav', 'm4a', 'aac', 'opus', 'wma']
for (const ext of audioExts) {
const mime = SHARE_MIME_MAP[ext]!
const routesToAudioPlayerFirst = mime.startsWith('audio/')
expect(routesToAudioPlayerFirst).toBe(true)
}
})
})