Implementar Web Share Target API para compartir fotos con la PWA
Some checks failed
build-and-deploy / build-and-deploy (push) Has been cancelled

- Agregar share_target al manifest de la PWA
- Crear endpoint /api/share-target para recibir archivos compartidos
- Guardar archivos temporalmente en /public/temp-shared
- Modificar UserProfileForm para aceptar imágenes externas
- Detectar automáticamente imágenes compartidas y procesarlas
- Crear endpoint /api/share-target/cleanup para limpiar temporales
- Mostrar toast informativo al recibir imagen compartida
- Redirigir automáticamente al formulario de perfil
- Soportar compartir desde galería, otras apps, etc.
This commit is contained in:
2025-10-17 18:29:00 -06:00
parent ced637a7a9
commit 5bb5e5092e
17 changed files with 367 additions and 1 deletions

View File

@@ -19,7 +19,8 @@
<!-- Formulario de edición de perfil o Lista de aplicaciones -->
<UserProfileForm
v-if="showProfileForm"
@close="showProfileForm = false"
:shared-image-url="sharedImageUrl"
@close="handleCloseProfileForm"
/>
<AuthApplicationsList v-else />
@@ -51,10 +52,38 @@
<script setup lang="ts">
const { isAuthenticated } = useAuthentik()
const { isNight } = useTheme()
const route = useRoute()
const router = useRouter()
// Estado para mostrar formulario de edición
const showProfileForm = ref(false)
// URL de imagen compartida (si existe)
const sharedImageUrl = ref<string | null>(null)
// Detectar si se compartió una imagen
onMounted(() => {
const sharedToken = route.query.shared as string
const sharedFile = route.query.file as string
if (sharedToken && sharedFile) {
// Construir URL del archivo temporal
sharedImageUrl.value = `/temp-shared/${sharedFile}`
// Abrir automáticamente el formulario de perfil
showProfileForm.value = true
// Limpiar query params de la URL sin recargar
router.replace({ query: {} })
}
})
// Manejar cierre del formulario
const handleCloseProfileForm = () => {
showProfileForm.value = false
sharedImageUrl.value = null
}
// Configurar meta tags para PWA
useHead({
htmlAttrs: {

View File

@@ -420,6 +420,11 @@
const { user } = useAuthentik()
const toast = useToast()
// Props
const props = defineProps<{
sharedImageUrl?: string | null
}>()
// Emits
const emit = defineEmits(['close'])
@@ -544,6 +549,13 @@ if (import.meta.client) {
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
// Detectar y procesar imagen compartida automáticamente
watch(() => props.sharedImageUrl, async (newUrl) => {
if (newUrl && import.meta.client) {
await processSharedImage(newUrl)
}
}, { immediate: true })
}
// Enviar formulario
@@ -802,6 +814,55 @@ const processImageFile = async (file: File) => {
const blob = new Blob([file], { type: file.type })
await handleAvatarCapture(blob)
}
// Procesar imagen compartida desde Web Share Target
const processSharedImage = async (imageUrl: string) => {
try {
// Mostrar notificación
toast.add({
title: 'Imagen compartida recibida',
description: 'Procesando imagen para tu avatar...',
color: 'info',
icon: 'i-heroicons-photo'
})
// Descargar la imagen del servidor
const response = await fetch(imageUrl)
if (!response.ok) {
throw new Error('No se pudo cargar la imagen compartida')
}
const blob = await response.blob()
// Validar que sea una imagen
if (!blob.type.startsWith('image/')) {
throw new Error('El archivo compartido no es una imagen válida')
}
// Procesar y subir la imagen
await handleAvatarCapture(blob)
// Limpiar archivo temporal del servidor
try {
await $fetch('/api/share-target/cleanup', {
method: 'POST',
body: { imageUrl }
})
} catch (cleanupError) {
console.error('Error limpiando archivo temporal:', cleanupError)
// No es crítico si falla la limpieza
}
} catch (error: any) {
console.error('Error procesando imagen compartida:', error)
toast.add({
title: 'Error',
description: error.message || 'No se pudo procesar la imagen compartida',
color: 'error',
icon: 'i-heroicons-exclamation-triangle'
})
}
}
</script>
<style scoped>

View File

@@ -50,6 +50,22 @@ export default defineNuxtConfig({
// NOTA: capture_links es experimental pero REQUERIDO para el funcionamiento correcto
// TypeScript dará error TS2353 pero se ignora intencionalmente (ver README.md)
capture_links: 'existing-client-navigate',
// Share Target API - Permite compartir fotos con la PWA
share_target: {
action: '/api/share-target',
method: 'POST',
enctype: 'multipart/form-data',
params: {
title: 'title',
text: 'text',
files: [
{
name: 'media',
accept: ['image/*']
}
]
}
},
// Extender scope a otros subdominios de Nucleo V3
scope_extensions: [
{

View File

@@ -0,0 +1,75 @@
/**
* API endpoint para Web Share Target
* POST /api/share-target
*
* Recibe imágenes compartidas desde otras apps y las almacena temporalmente
*/
import { promises as fs } from 'fs'
import { join } from 'path'
import { createHash, randomBytes } from 'crypto'
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg']
const TEMP_DIR = 'public/temp-shared'
export default defineEventHandler(async (event) => {
try {
// Leer el body como multipart/form-data
const formData = await readMultipartFormData(event)
if (!formData || formData.length === 0) {
// No hay archivo, redirigir a inicio
return sendRedirect(event, '/', 302)
}
// Buscar el campo 'media' (nombre definido en manifest)
const mediaFile = formData.find(field => field.name === 'media')
if (!mediaFile) {
// No hay imagen, redirigir a inicio
return sendRedirect(event, '/', 302)
}
// Validar tipo de archivo
const contentType = mediaFile.type || ''
if (!ALLOWED_TYPES.includes(contentType)) {
console.error('Tipo de archivo no permitido:', contentType)
return sendRedirect(event, '/', 302)
}
// Validar tamaño
if (mediaFile.data.length > MAX_FILE_SIZE) {
console.error('Archivo muy grande:', mediaFile.data.length)
return sendRedirect(event, '/', 302)
}
// Crear directorio temporal si no existe
const tempPath = join(process.cwd(), TEMP_DIR)
try {
await fs.access(tempPath)
} catch {
await fs.mkdir(tempPath, { recursive: true })
}
// Generar token único para este archivo temporal
const token = randomBytes(16).toString('hex')
const timestamp = Date.now()
const extension = contentType.split('/')[1]
const filename = `shared-${token}-${timestamp}.${extension}`
const filepath = join(tempPath, filename)
// Guardar archivo temporal
await fs.writeFile(filepath, mediaFile.data)
// URL pública del archivo temporal
const tempUrl = `/temp-shared/${filename}`
console.log(`✅ Imagen compartida guardada temporalmente: ${tempUrl}`)
// Redirigir a la página principal con el token
return sendRedirect(event, `/?shared=${token}&file=${encodeURIComponent(filename)}`, 302)
} catch (error: any) {
console.error('Error en share-target:', error)
return sendRedirect(event, '/', 302)
}
})

View File

@@ -0,0 +1,68 @@
/**
* API endpoint para limpiar archivos temporales de Web Share Target
* POST /api/share-target/cleanup
*
* Elimina archivos temporales después de ser procesados
*/
import { promises as fs } from 'fs'
import { join } from 'path'
export default defineEventHandler(async (event) => {
try {
const body = await readBody(event)
const { imageUrl } = body
if (!imageUrl || typeof imageUrl !== 'string') {
throw createError({
statusCode: 400,
message: 'imageUrl es requerido'
})
}
// Extraer el filename de la URL (/temp-shared/filename.jpg)
const filename = imageUrl.split('/').pop()
if (!filename || !filename.startsWith('shared-')) {
throw createError({
statusCode: 400,
message: 'URL de imagen inválida'
})
}
// Construir ruta completa del archivo
const filepath = join(process.cwd(), 'public', 'temp-shared', filename)
// Intentar eliminar el archivo
try {
await fs.unlink(filepath)
console.log(`🗑️ Archivo temporal eliminado: ${filename}`)
return {
success: true,
message: 'Archivo temporal eliminado correctamente'
}
} catch (err: any) {
// Si el archivo no existe, no es un error crítico
if (err.code === 'ENOENT') {
console.log(`⚠️ Archivo temporal ya no existe: ${filename}`)
return {
success: true,
message: 'Archivo ya había sido eliminado'
}
}
throw err
}
} catch (error: any) {
console.error('Error en cleanup de share-target:', error)
// Re-throw si ya es un error de createError
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
message: error.message || 'Error al eliminar archivo temporal'
})
}
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

30
pwa_assets/index.html Normal file
View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- El manifest proporciona información sobre el nombre, iconos y URL de inicio de la app 【407831617096888†L240-L248】. -->
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#ffffff">
<title>DayNight PWA</title>
<link rel="icon" href="icons/icon-192x192.png" sizes="192x192">
</head>
<body>
<h1>Bienvenido a la app DayNight PWA</h1>
<p>Esta es una versión instalable de la app con soporte offline.</p>
<script>
// Registramos el service worker para habilitar caché y funcionalidad offline【407555884650804†L205-L213】.
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js')
.then(function(reg) {
console.log('Service worker registrado con scope:', reg.scope);
}).catch(function(err) {
console.error('Error al registrar el service worker:', err);
});
});
}
</script>
</body>
</html>

22
pwa_assets/manifest.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "DayNight PWA",
"short_name": "DayNight",
"description": "Aplicación de ejemplo PWA instalable y offline.",
"id": "/",
"start_url": "/index.html",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{ "src": "icons/icon-72x72.png", "sizes": "72x72", "type": "image/png" },
{ "src": "icons/icon-96x96.png", "sizes": "96x96", "type": "image/png" },
{ "src": "icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" },
{ "src": "icons/icon-144x144.png", "sizes": "144x144", "type": "image/png" },
{ "src": "icons/icon-152x152.png", "sizes": "152x152", "type": "image/png" },
{ "src": "icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "icons/icon-256x256.png", "sizes": "256x256", "type": "image/png" },
{ "src": "icons/icon-384x384.png", "sizes": "384x384", "type": "image/png" },
{ "src": "icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}

65
pwa_assets/sw.js Normal file
View File

@@ -0,0 +1,65 @@
/*
Service worker para la app DayNight. Este script cachea recursos estáticos
durante la instalación para que se puedan servir sin conexión. Según MDN, en
el evento de instalación se deben abrir los caches y agregar recursos con
`addAll()`【407555884650804†L360-L410】, y luego interceptar las peticiones y
responder desde el cache cuando sea posible【407555884650804†L205-L213】.
*/
const CACHE_NAME = 'daynight-cache-v1';
const ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/icons/icon-72x72.png',
'/icons/icon-96x96.png',
'/icons/icon-128x128.png',
'/icons/icon-144x144.png',
'/icons/icon-152x152.png',
'/icons/icon-192x192.png',
'/icons/icon-256x256.png',
'/icons/icon-384x384.png',
'/icons/icon-512x512.png'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(ASSETS);
})
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((names) => {
return Promise.all(
names.map((name) => {
if (name !== CACHE_NAME) {
return caches.delete(name);
}
})
);
})
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) {
return cached;
}
return fetch(event.request).then((networkResponse) => {
if (!networkResponse || networkResponse.status !== 200 || networkResponse.type !== 'basic') {
return networkResponse;
}
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return networkResponse;
});
})
);
});