Skip to content

Commit 0709ced

Browse files
committed
feat: links externos via open_url, fix descarga media y tip atajos en ajustes
PostDetailScreen: - Añadir helper openInBrowser(): usa invoke('open_url') en Tauri y window.open() como fallback en browser, evitando WebView2 interno - Convertir 'Ver en Threads', chip URL y 'Abrir en Threads' de <a> a <button onclick={openInBrowser}> para abrir siempre en browser del sistema - fix: downloadMedia en Tauri usa openInBrowser(media.url) directamente — WebView2 no puede descargar data: URIs ni URLs cross-origin (Instagram, Giphy). Si está cacheado como data URL, descarga directa solo en modo web - fix: downloadInlineVideo fallback usa openInBrowser en lugar de window.open SettingsScreen: - Añadir sección colapsable 'Atajos de teclado' al pie de ajustes: chip con icono de teclado, acordeón max-height con transición suave, badges kbd estilizados y columna de contexto (Global / En vault / En post)
1 parent d4e94b6 commit 0709ced

2 files changed

Lines changed: 140 additions & 34 deletions

File tree

src/routes/PostDetailScreen.svelte

Lines changed: 63 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
import { onMount } from 'svelte'
33
import { get } from 'svelte/store'
4+
import { invoke } from '@tauri-apps/api/core'
45
import { categories, deletePost, loadVault, posts, savePost } from '../lib/stores/vault'
56
import { getStorage } from '../lib/storage/index'
67
import CategoryBadge from '../components/CategoryBadge.svelte'
@@ -101,6 +102,16 @@
101102
editingNote = false
102103
}
103104
105+
// Abre una URL en el browser del sistema (no en el WebView interno de Tauri).
106+
// En desktop usa open_url (crate open, Rust). En browser usa window.open().
107+
function openInBrowser(url: string) {
108+
if ('__TAURI_INTERNALS__' in window) {
109+
void invoke('open_url', { url })
110+
} else {
111+
window.open(url, '_blank', 'noopener,noreferrer')
112+
}
113+
}
114+
104115
function formatDate(ts: number): string {
105116
return new Date(ts).toLocaleDateString('es', {
106117
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric'
@@ -117,13 +128,34 @@
117128
}
118129
}
119130
131+
/*
132+
PBL: <a download> no funciona en URLs cross-origin (CDN Instagram/Threads).
133+
El WebView2/browser ignora el atributo download si el dominio es distinto al de la app.
134+
Fix: fetch() → Blob → Object URL (mismo origen) → <a download> ya funciona.
135+
Si está cacheado como data URL, descarga directa sin red.
136+
*/
120137
function downloadMedia(media: PostMedia) {
121-
const a = document.createElement('a')
122-
a.href = getMediaSource(media)
123-
a.download = fileNameFromUrl(media.url)
124-
a.target = '_blank'
125-
a.rel = 'noopener noreferrer'
126-
a.click()
138+
// En Tauri, WebView2 no puede descargar data: URIs ni URLs cross-origin.
139+
// La solución más fiable: abrir la URL original en el browser del sistema.
140+
if ('__TAURI_INTERNALS__' in window) {
141+
openInBrowser(media.url)
142+
return
143+
}
144+
145+
// Fallback para modo browser web: intentar descarga directa vía data URL
146+
const src = getMediaSource(media)
147+
if (src.startsWith('data:')) {
148+
const filename = fileNameFromUrl(media.url)
149+
const a = document.createElement('a')
150+
a.href = src
151+
a.download = filename
152+
document.body.appendChild(a)
153+
a.click()
154+
document.body.removeChild(a)
155+
return
156+
}
157+
158+
openInBrowser(src)
127159
}
128160
129161
function getInlineVideoState(media: PostMedia) {
@@ -244,17 +276,24 @@
244276
}
245277
}
246278
247-
function downloadInlineVideo(media: PostMedia) {
248-
const state = getInlineVideoState(media)
279+
async function downloadInlineVideo(media: PostMedia) {
280+
const state = getInlineVideoState(media)
249281
const source = state.downloadSrc ?? state.src
250282
if (!source) return
251283
252-
const a = document.createElement('a')
253-
a.href = source
254-
a.download = fileNameFromUrl(source)
255-
a.target = '_blank'
256-
a.rel = 'noopener noreferrer'
257-
a.click()
284+
const filename = fileNameFromUrl(source)
285+
try {
286+
const res = await fetch(source)
287+
const blob = await res.blob()
288+
const blobUrl = URL.createObjectURL(blob)
289+
const a = document.createElement('a')
290+
a.href = blobUrl
291+
a.download = filename
292+
a.click()
293+
setTimeout(() => URL.revokeObjectURL(blobUrl), 15_000)
294+
} catch {
295+
openInBrowser(source)
296+
}
258297
}
259298
260299
function toImageProxyUrl(url: string): string {
@@ -834,19 +873,16 @@
834873
>Descargar</button>
835874
{/if}
836875

837-
<a
838-
href={media.url}
839-
target="_blank"
840-
rel="noopener noreferrer"
876+
<button
877+
onclick={() => openInBrowser(media.url)}
841878
class="px-3 py-1.5 rounded-lg text-xs font-semibold transition-all duration-200"
842879
style="
843880
background: rgba(124,77,255,0.22);
844881
border: 1px solid rgba(124,77,255,0.4);
845882
color: #e4d6ff;
846883
font-family: var(--font-display);
847-
text-decoration: none;
848884
"
849-
>Ver en Threads ↗</a>
885+
>Ver en Threads ↗</button>
850886
</div>
851887
</div>
852888
{:else}
@@ -874,13 +910,11 @@
874910
font-family: var(--font-display);
875911
"
876912
>Descargar</button>
877-
<a
878-
href={media.url}
879-
target="_blank"
880-
rel="noopener noreferrer"
881-
class="text-xs truncate"
913+
<button
914+
onclick={() => openInBrowser(media.url)}
915+
class="text-xs truncate text-left"
882916
style="color: var(--vault-on-bg-muted)"
883-
>{media.url}</a>
917+
>{media.url}</button>
884918
</div>
885919
{/if}
886920
</div>
@@ -914,18 +948,15 @@
914948
target="_blank" + rel="noopener noreferrer" = seguridad básica
915949
para links externos (previene que la nueva pestaña acceda a window.opener).
916950
-->
917-
<a
918-
href={cleanThreadsUrl(post.url)}
919-
target="_blank"
920-
rel="noopener noreferrer"
951+
<button
952+
onclick={() => openInBrowser(cleanThreadsUrl(post.url))}
921953
class="flex items-center justify-center gap-2.5 w-full py-3.5 rounded-2xl font-semibold transition-all duration-200"
922954
style="
923955
background: rgba(255,255,255,0.05);
924956
border: 1px solid rgba(255,255,255,0.11);
925957
color: var(--vault-on-bg);
926958
font-family: var(--font-display);
927959
font-size: 0.9rem;
928-
text-decoration: none;
929960
letter-spacing: 0.02em;
930961
"
931962
onmouseenter={(e) => {
@@ -947,6 +978,6 @@
947978
<line x1="10" y1="14" x2="21" y2="3"/>
948979
</svg>
949980
Abrir en Threads
950-
</a>
981+
</button>
951982
{/if}
952983
</div>

src/routes/SettingsScreen.svelte

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010
let importError = $state('')
1111
// pendingFile guarda el archivo seleccionado mientras el usuario decide si confirmar.
1212
// File | null significa: puede ser un objeto File (archivo) o null (ninguno pendiente).
13-
let pendingFile = $state<File | null>(null)
14-
let showAboutDev = $state(false)
13+
let pendingFile = $state<File | null>(null)
14+
let showAboutDev = $state(false)
15+
let showShortcuts = $state(false)
1516
1617
async function openExternal(url: string) {
1718
if ('__TAURI_INTERNALS__' in window) {
@@ -292,6 +293,80 @@
292293
</svg>
293294
</button>
294295
</div>
296+
297+
<!-- ── Tip: atajos de teclado ───────────────────────────────────────────
298+
Sin card dedicada — fila colapsable sutil al pie del layout.
299+
Patrón: chip con icono de teclado + acordeón max-height.
300+
-->
301+
<div class="mt-6 mb-2">
302+
<button
303+
onclick={() => showShortcuts = !showShortcuts}
304+
class="flex items-center gap-1.5 text-xs transition-colors duration-200"
305+
style="color: var(--vault-on-bg-muted); background: none; border: none; padding: 0; cursor: pointer;"
306+
onmouseenter={(e) => (e.currentTarget as HTMLElement).style.color = 'var(--vault-on-bg)'}
307+
onmouseleave={(e) => (e.currentTarget as HTMLElement).style.color = 'var(--vault-on-bg-muted)'}
308+
aria-expanded={showShortcuts}
309+
>
310+
<!-- Icono teclado -->
311+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="shrink-0:0; opacity:0.7">
312+
<rect x="2" y="6" width="20" height="14" rx="2"/>
313+
<line x1="6" y1="10" x2="6" y2="10"/><line x1="10" y1="10" x2="10" y2="10"/>
314+
<line x1="14" y1="10" x2="14" y2="10"/><line x1="18" y1="10" x2="18" y2="10"/>
315+
<line x1="6" y1="14" x2="6" y2="14"/><line x1="18" y1="14" x2="18" y2="14"/>
316+
<line x1="10" y1="14" x2="14" y2="14"/>
317+
</svg>
318+
<span style="font-family: var(--font-body)">Atajos de teclado</span>
319+
<!-- Chevron rotatorio -->
320+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
321+
style="transition: transform 0.22s ease; transform: rotate({showShortcuts ? 180 : 0}deg); opacity:0.5"
322+
>
323+
<polyline points="6 9 12 15 18 9"/>
324+
</svg>
325+
</button>
326+
327+
<!-- Acordeón — max-height transition estándar -->
328+
<div style="
329+
max-height: {showShortcuts ? '280px' : '0'};
330+
overflow: hidden;
331+
transition: max-height 0.28s cubic-bezier(0.4, 0, 0.2, 1);
332+
">
333+
<div class="mt-3 flex flex-col gap-1" style="opacity: {showShortcuts ? 1 : 0}; transition: opacity 0.2s ease 0.05s">
334+
{#each [
335+
{ keys: ['Esc'], desc: 'Volver atrás', ctx: 'Global' },
336+
{ keys: ['Ctrl', 'N'], desc: 'Añadir post', ctx: 'Global' },
337+
{ keys: ['/'], desc: 'Buscar posts', ctx: 'En vault' },
338+
{ keys: ['Ctrl', 'F'], desc: 'Buscar en la app', ctx: 'Global' },
339+
{ keys: ['', ''], desc: 'Navegar entre posts', ctx: 'En post' },
340+
] as shortcut}
341+
<div class="flex items-center gap-3 py-1">
342+
<div class="flex items-center gap-1 shrink-0" style="min-width: 110px">
343+
{#each shortcut.keys as key, i}
344+
{#if i > 0}<span style="color: var(--vault-on-bg-muted); font-size: 9px; opacity:0.5">+</span>{/if}
345+
<span style="
346+
font-family: var(--font-mono, 'DM Mono', monospace);
347+
font-size: 10px;
348+
padding: 1px 6px;
349+
border-radius: 4px;
350+
border: 1px solid rgba(255,255,255,0.13);
351+
background: rgba(255,255,255,0.05);
352+
color: var(--vault-on-bg);
353+
line-height: 1.6;
354+
">{key}</span>
355+
{/each}
356+
</div>
357+
<span class="text-xs" style="color: var(--vault-on-bg); flex:1">{shortcut.desc}</span>
358+
<span class="text-xs" style="
359+
color: var(--vault-on-bg-muted);
360+
font-family: var(--font-body);
361+
font-size: 10px;
362+
opacity: 0.5;
363+
">{shortcut.ctx}</span>
364+
</div>
365+
{/each}
366+
</div>
367+
</div>
368+
</div>
369+
295370
</div>
296371

297372
<!-- ── Modal About Dev ─────────────────────────────────────────────────── -->

0 commit comments

Comments
 (0)