mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: add configurable download filename patterns
This commit is contained in:
@@ -228,8 +228,7 @@ function openBook() {
|
||||
function downloadFile() {
|
||||
if (!primaryFile.value) return
|
||||
const a = document.createElement('a')
|
||||
a.href = `/api/v1/books/files/${primaryFile.value.id}/serve`
|
||||
a.download = `book.${primaryFile.value.format ?? 'epub'}`
|
||||
a.href = `/api/v1/books/files/${primaryFile.value.id}/serve?download=1`
|
||||
a.click()
|
||||
}
|
||||
|
||||
|
||||
@@ -45,8 +45,7 @@ function openFile(file: BookDetailFile) {
|
||||
|
||||
function downloadFile(file: BookDetailFile) {
|
||||
const a = document.createElement('a')
|
||||
a.href = `/api/v1/books/files/${file.id}/serve`
|
||||
a.download = file.filename ?? `book.${file.format ?? 'epub'}`
|
||||
a.href = `/api/v1/books/files/${file.id}/serve?download=1`
|
||||
a.click()
|
||||
}
|
||||
|
||||
|
||||
@@ -260,8 +260,7 @@ function currentZoomValue(): string {
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<a
|
||||
:href="`/api/v1/books/files/${fileId}/serve`"
|
||||
download
|
||||
:href="`/api/v1/books/files/${fileId}/serve?download=1`"
|
||||
class="viewer-btn flex items-center justify-center"
|
||||
>
|
||||
<Download :size="14" />
|
||||
@@ -346,4 +345,3 @@ function currentZoomValue(): string {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Check, ChevronDown, ChevronUp, FileText, Loader2, RotateCcw, Save } from 'lucide-vue-next'
|
||||
import { DEFAULT_UPLOAD_PATTERN, PATTERN_TOKENS } from '@projectx/types'
|
||||
import { DEFAULT_DOWNLOAD_PATTERN, DEFAULT_UPLOAD_PATTERN, PATTERN_TOKENS } from '@projectx/types'
|
||||
import { useFileNamingPattern } from './composables/useFileNamingPattern'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
const {
|
||||
globalPattern,
|
||||
globalError,
|
||||
downloadPattern,
|
||||
downloadError,
|
||||
libraries,
|
||||
loadingGlobal,
|
||||
savingGlobal,
|
||||
loadingDownload,
|
||||
savingDownload,
|
||||
savingLibraryId,
|
||||
fetchGlobalPattern,
|
||||
fetchDownloadPattern,
|
||||
fetchLibraries,
|
||||
onGlobalPatternInput,
|
||||
onDownloadPatternInput,
|
||||
saveGlobalPattern,
|
||||
saveDownloadPattern,
|
||||
saveLibraryPattern,
|
||||
clearLibraryPattern,
|
||||
getEffectivePreview,
|
||||
previewDownloadName,
|
||||
previewPath,
|
||||
} = useFileNamingPattern()
|
||||
|
||||
@@ -50,7 +58,7 @@ const EXAMPLES = [
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchGlobalPattern(), fetchLibraries()])
|
||||
await Promise.all([fetchGlobalPattern(), fetchDownloadPattern(), fetchLibraries()])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -60,13 +68,13 @@ onMounted(async () => {
|
||||
<div>
|
||||
<h2 class="settings-title">File Naming</h2>
|
||||
<p class="settings-subtitle">
|
||||
Control how uploaded files are named and organized on disk. Patterns apply when a file is uploaded.
|
||||
Control naming patterns for uploads and downloads using the same placeholder syntax.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Global default -->
|
||||
<!-- Upload global default -->
|
||||
<div>
|
||||
<p class="settings-group-label">Default Pattern</p>
|
||||
<p class="settings-group-label">Upload Pattern</p>
|
||||
<div class="border border-border rounded-lg bg-card px-5 py-5 space-y-4">
|
||||
<p class="text-xs text-muted-foreground leading-relaxed">
|
||||
Applied to all libraries unless overridden. Leave blank to use the built-in default layout.
|
||||
@@ -101,6 +109,43 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download global default -->
|
||||
<div>
|
||||
<p class="settings-group-label">Download Pattern</p>
|
||||
<div class="border border-border rounded-lg bg-card px-5 py-5 space-y-4">
|
||||
<p class="text-xs text-muted-foreground leading-relaxed">
|
||||
Controls suggested filenames for single-file downloads and files inside export ZIPs.
|
||||
</p>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
:value="downloadPattern"
|
||||
type="text"
|
||||
:placeholder="DEFAULT_DOWNLOAD_PATTERN"
|
||||
class="flex-1 h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
:class="downloadError ? 'border-destructive focus:ring-destructive' : ''"
|
||||
:disabled="loadingDownload"
|
||||
@input="onDownloadPatternInput(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<button
|
||||
class="flex items-center gap-1.5 h-8 px-3 rounded-md border border-border bg-background text-sm text-foreground hover:bg-muted transition-colors disabled:opacity-50"
|
||||
:disabled="savingDownload || !!downloadError || loadingDownload"
|
||||
@click="saveDownloadPattern"
|
||||
>
|
||||
<Loader2 v-if="savingDownload" :size="13" class="animate-spin" />
|
||||
<Save v-else :size="13" />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="downloadError" class="text-xs text-destructive">{{ downloadError }}</p>
|
||||
<p class="text-xs text-muted-foreground font-mono">
|
||||
Preview: <span class="text-foreground">{{ previewDownloadName(downloadPattern) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-library overrides -->
|
||||
<div>
|
||||
<p class="settings-group-label">Library Overrides</p>
|
||||
@@ -229,7 +274,7 @@ onMounted(async () => {
|
||||
<p class="text-xs font-medium text-foreground mb-1.5">Folder-only mode</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
End the pattern with <code class="bg-muted px-1 py-0.5 rounded font-mono text-foreground">/</code> to specify a folder only - the
|
||||
original filename is preserved inside it.
|
||||
original filename is preserved inside it. This affects upload destinations and ZIP export paths.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { api } from '@/lib/api'
|
||||
import { EXAMPLE_PATTERN_METADATA, resolveUploadPath, validatePattern } from '@projectx/types'
|
||||
import {
|
||||
DEFAULT_DOWNLOAD_PATTERN,
|
||||
EXAMPLE_PATTERN_METADATA,
|
||||
resolveDownloadFilename,
|
||||
resolveUploadPath,
|
||||
validatePattern,
|
||||
} from '@projectx/types'
|
||||
import type { Library } from '@projectx/types'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
|
||||
@@ -14,11 +20,20 @@ export function previewPath(pattern: string): string {
|
||||
return resolved.startsWith('/') ? resolved : `/${resolved}`
|
||||
}
|
||||
|
||||
export function previewDownloadName(pattern: string): string {
|
||||
const resolved = resolveDownloadFilename(pattern || DEFAULT_DOWNLOAD_PATTERN, EXAMPLE_PATTERN_METADATA, 'epub')
|
||||
return resolved || 'neuromancer.epub'
|
||||
}
|
||||
|
||||
export function useFileNamingPattern() {
|
||||
const globalPattern = ref('')
|
||||
const globalError = ref('')
|
||||
const loadingGlobal = ref(false)
|
||||
const savingGlobal = ref(false)
|
||||
const downloadPattern = ref('')
|
||||
const downloadError = ref('')
|
||||
const loadingDownload = ref(false)
|
||||
const savingDownload = ref(false)
|
||||
const savingLibraryId = ref<number | null>(null)
|
||||
|
||||
const { libraries, fetchLibraries } = useLibraries()
|
||||
@@ -36,11 +51,29 @@ export function useFileNamingPattern() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDownloadPattern() {
|
||||
loadingDownload.value = true
|
||||
try {
|
||||
const res = await api('/api/v1/app-settings/download-pattern')
|
||||
if (res.ok) {
|
||||
const data: { pattern: string } = await res.json()
|
||||
downloadPattern.value = data.pattern
|
||||
}
|
||||
} finally {
|
||||
loadingDownload.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onGlobalPatternInput(value: string) {
|
||||
globalPattern.value = value
|
||||
globalError.value = value && !validatePattern(value) ? 'Pattern contains invalid characters' : ''
|
||||
}
|
||||
|
||||
function onDownloadPatternInput(value: string) {
|
||||
downloadPattern.value = value
|
||||
downloadError.value = value && !validatePattern(value) ? 'Pattern contains invalid characters' : ''
|
||||
}
|
||||
|
||||
async function saveGlobalPattern() {
|
||||
if (globalError.value) return
|
||||
savingGlobal.value = true
|
||||
@@ -60,6 +93,25 @@ export function useFileNamingPattern() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDownloadPattern() {
|
||||
if (downloadError.value) return
|
||||
savingDownload.value = true
|
||||
try {
|
||||
const res = await api('/api/v1/app-settings/download-pattern', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pattern: downloadPattern.value }),
|
||||
})
|
||||
if (res.ok) {
|
||||
toast.success('Download pattern saved')
|
||||
} else {
|
||||
toast.error('Failed to save download pattern')
|
||||
}
|
||||
} finally {
|
||||
savingDownload.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLibraryPattern(library: Library) {
|
||||
savingLibraryId.value = library.id
|
||||
try {
|
||||
@@ -90,17 +142,25 @@ export function useFileNamingPattern() {
|
||||
return {
|
||||
globalPattern,
|
||||
globalError,
|
||||
downloadPattern,
|
||||
downloadError,
|
||||
libraries,
|
||||
loadingGlobal,
|
||||
savingGlobal,
|
||||
loadingDownload,
|
||||
savingDownload,
|
||||
savingLibraryId,
|
||||
fetchGlobalPattern,
|
||||
fetchDownloadPattern,
|
||||
fetchLibraries,
|
||||
onGlobalPatternInput,
|
||||
onDownloadPatternInput,
|
||||
saveGlobalPattern,
|
||||
saveDownloadPattern,
|
||||
saveLibraryPattern,
|
||||
clearLibraryPattern,
|
||||
getEffectivePreview,
|
||||
previewPath,
|
||||
previewDownloadName,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user