feat: Implement Web Share API for mobile image sharing and standardize prompt textarea heights across views.

This commit is contained in:
xds
2026-02-17 18:17:00 +03:00
parent 674cbb8f16
commit f8adcf33d3
3 changed files with 53 additions and 29 deletions

View File

@@ -1149,9 +1149,8 @@ const confirmAddToAlbum = async () => {
@click="clearPrompt" />
</div>
</div>
<Textarea v-model="prompt" rows="3" autoResize
placeholder="Describe what you want to create..."
class="w-full bg-slate-800 !text-[16px] border-white/10 text-white rounded-xl p-3 focus:border-violet-500 focus:ring-1 focus:ring-violet-500/50 transition-all resize-none shadow-inner" />
<Textarea v-model="prompt" rows="2" placeholder="Describe what you want to create..."
class="w-full bg-slate-800 !h-28 !text-[16px] border-white/10 text-white rounded-xl p-3 focus:border-violet-500 focus:ring-1 focus:ring-violet-500/50 transition-all resize-none shadow-inner" />
</div>

View File

@@ -674,16 +674,47 @@ const downloadSelected = async () => {
const projectId = localStorage.getItem('active_project_id')
if (projectId) headers['X-Project-ID'] = projectId
// Multi-file ZIP (if > 1)
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || navigator.maxTouchPoints > 1
const canShare = isMobile && navigator.canShare && navigator.share
if (canShare) {
// Attempt to fetch all files and share
try {
const files = []
for (const assetId of ids) {
const url = API_URL + '/assets/' + assetId
const resp = await fetch(url, { headers })
const blob = await resp.blob()
const mime = blob.type || 'image/png'
const ext = mime.split('/')[1] || 'png'
files.push(new File([blob], `image-${assetId}.${ext}`, { type: mime }))
}
if (navigator.canShare({ files })) {
await navigator.share({ files })
toast.add({ severity: 'success', summary: 'Shared', detail: `${files.length} images shared`, life: 2000 })
return // Success, exit
}
} catch (shareError) {
console.warn('Share failed or canceled, falling back to zip if multiple', shareError)
// Fallthrough to zip/single download logic if share fails (e.g. user cancelled or limit reached)
// actually if user canceled, we probably shouldn't zip. But hard to distinguish.
// If it was a real error, zip might be better.
if (shareError.name !== 'AbortError' && ids.length > 1) {
toast.add({ severity: 'info', summary: 'Sharing failed', detail: 'Creating zip archive instead...', life: 2000 })
} else {
return // Stop if just cancelled
}
}
}
// Fallback: ZIP for multiple, Direct for single (if sharing skipped/failed)
if (ids.length > 1) {
const zip = new JSZip()
// Sanitize idea name for filename (fallback to 'session' if no name)
const safeName = (currentIdea.value?.name || 'session').replace(/[^a-z0-9_\- ]/gi, '').trim().replace(/\s+/g, '_').toLowerCase()
const folderName = `${safeName}_assets`
let successCount = 0
// Sequential fetch to avoid overwhelming network but could be parallel
await Promise.all(ids.map(async (assetId) => {
try {
const url = API_URL + '/assets/' + assetId
@@ -715,18 +746,13 @@ const downloadSelected = async () => {
toast.add({ severity: 'success', summary: 'Archived', detail: `${successCount} images saved to zip`, life: 3000 })
} else {
// Single File
// Single File Download (Desktop or Mobile Fallback)
const assetId = ids[0]
const url = API_URL + '/assets/' + assetId
const resp = await fetch(url, { headers })
const blob = await resp.blob()
// Use share sheet only on mobile, direct download on desktop
const file = new File([blob], assetId + '.png', { type: blob.type || 'image/png' })
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || navigator.maxTouchPoints > 1
if (isMobile && navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({ files: [file] })
} else {
const a = document.createElement('a')
a.href = URL.createObjectURL(file)
a.download = file.name
@@ -734,7 +760,7 @@ const downloadSelected = async () => {
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(a.href)
}
toast.add({ severity: 'success', summary: 'Downloaded', detail: `Image saved`, life: 2000 })
}
} catch (e) {
@@ -1055,9 +1081,8 @@ watch(viewMode, (v) => {
@click="clearPrompt" />
</div>
</div>
<Textarea v-model="prompt" rows="2" autoResize
placeholder="Describe what you want to create..."
class="w-full bg-slate-800 !text-sm border-white/10 text-white rounded-lg p-2 focus:border-violet-500 focus:ring-1 focus:ring-violet-500/50 transition-all resize-none shadow-inner" />
<Textarea v-model="prompt" rows="2" placeholder="Describe what you want to create..."
class="w-full !h-28 bg-slate-800 !text-sm border-white/10 text-white rounded-lg p-2 focus:border-violet-500 focus:ring-1 focus:ring-violet-500/50 transition-all resize-none shadow-inner" />
</div>
<div class="flex flex-col md:flex-row gap-2">

View File

@@ -331,9 +331,9 @@ const handleAssetPickerUpload = async (event) => {
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Prompt</label>
<!-- Optional: Add Clear/Improve buttons here if desired, keeping simple for now to match layout -->
</div>
<Textarea v-model="prompt" rows="2" autoResize
<Textarea v-model="prompt" rows="2"
placeholder="Describe what you want to create... (Auto-starts new session)"
class="w-full bg-slate-800 !text-sm border-white/10 text-white rounded-lg p-2 focus:border-violet-500 focus:ring-1 focus:ring-violet-500/50 transition-all resize-none shadow-inner" />
class="w-full !h-28 bg-slate-800 !text-sm border-white/10 text-white rounded-lg p-2 focus:border-violet-500 focus:ring-1 focus:ring-violet-500/50 transition-all resize-none shadow-inner" />
</div>
<!-- Character & Assets Row -->