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

@@ -1027,7 +1027,7 @@ const confirmAddToAlbum = async () => {
</div> </div>
<i class="pi pi-spin pi-spinner text-violet-500 text-xl mb-2 relative z-10"></i> <i class="pi pi-spin pi-spinner text-violet-500 text-xl mb-2 relative z-10"></i>
<span class="text-[10px] text-violet-300/70 relative z-10 capitalize">{{ item.status <span class="text-[10px] text-violet-300/70 relative z-10 capitalize">{{ item.status
}}...</span> }}...</span>
<span v-if="item.progress" <span v-if="item.progress"
class="text-[9px] text-violet-400/60 font-mono mt-1 relative z-10">{{ class="text-[9px] text-violet-400/60 font-mono mt-1 relative z-10">{{
item.progress }}%</span> item.progress }}%</span>
@@ -1088,7 +1088,7 @@ const confirmAddToAlbum = async () => {
@click.stop="reuseAsset(item)" /> @click.stop="reuseAsset(item)" />
</div> </div>
<p class="text-[10px] text-white/70 line-clamp-1 leading-tight">{{ item.prompt <p class="text-[10px] text-white/70 line-clamp-1 leading-tight">{{ item.prompt
}}</p> }}</p>
</div> </div>
</div> </div>
@@ -1149,9 +1149,8 @@ const confirmAddToAlbum = async () => {
@click="clearPrompt" /> @click="clearPrompt" />
</div> </div>
</div> </div>
<Textarea v-model="prompt" rows="3" autoResize <Textarea v-model="prompt" rows="2" placeholder="Describe what you want to create..."
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" />
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" />
</div> </div>

View File

@@ -674,16 +674,47 @@ const downloadSelected = async () => {
const projectId = localStorage.getItem('active_project_id') const projectId = localStorage.getItem('active_project_id')
if (projectId) headers['X-Project-ID'] = projectId 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) { if (ids.length > 1) {
const zip = new JSZip() 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 safeName = (currentIdea.value?.name || 'session').replace(/[^a-z0-9_\- ]/gi, '').trim().replace(/\s+/g, '_').toLowerCase()
const folderName = `${safeName}_assets` const folderName = `${safeName}_assets`
let successCount = 0 let successCount = 0
// Sequential fetch to avoid overwhelming network but could be parallel
await Promise.all(ids.map(async (assetId) => { await Promise.all(ids.map(async (assetId) => {
try { try {
const url = API_URL + '/assets/' + assetId const url = API_URL + '/assets/' + assetId
@@ -715,26 +746,21 @@ const downloadSelected = async () => {
toast.add({ severity: 'success', summary: 'Archived', detail: `${successCount} images saved to zip`, life: 3000 }) toast.add({ severity: 'success', summary: 'Archived', detail: `${successCount} images saved to zip`, life: 3000 })
} else { } else {
// Single File // Single File Download (Desktop or Mobile Fallback)
const assetId = ids[0] const assetId = ids[0]
const url = API_URL + '/assets/' + assetId const url = API_URL + '/assets/' + assetId
const resp = await fetch(url, { headers }) const resp = await fetch(url, { headers })
const blob = await resp.blob() 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 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] })) { const a = document.createElement('a')
await navigator.share({ files: [file] }) a.href = URL.createObjectURL(file)
} else { a.download = file.name
const a = document.createElement('a') document.body.appendChild(a)
a.href = URL.createObjectURL(file) a.click()
a.download = file.name document.body.removeChild(a)
document.body.appendChild(a) URL.revokeObjectURL(a.href)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(a.href)
}
toast.add({ severity: 'success', summary: 'Downloaded', detail: `Image saved`, life: 2000 }) toast.add({ severity: 'success', summary: 'Downloaded', detail: `Image saved`, life: 2000 })
} }
} catch (e) { } catch (e) {
@@ -1055,9 +1081,8 @@ watch(viewMode, (v) => {
@click="clearPrompt" /> @click="clearPrompt" />
</div> </div>
</div> </div>
<Textarea v-model="prompt" rows="2" autoResize <Textarea v-model="prompt" rows="2" placeholder="Describe what you want to create..."
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" />
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" />
</div> </div>
<div class="flex flex-col md:flex-row gap-2"> <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> <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 --> <!-- Optional: Add Clear/Improve buttons here if desired, keeping simple for now to match layout -->
</div> </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)" 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> </div>
<!-- Character & Assets Row --> <!-- Character & Assets Row -->