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.
@@ -19,7 +19,8 @@
|
|||||||
<!-- Formulario de edición de perfil o Lista de aplicaciones -->
|
<!-- Formulario de edición de perfil o Lista de aplicaciones -->
|
||||||
<UserProfileForm
|
<UserProfileForm
|
||||||
v-if="showProfileForm"
|
v-if="showProfileForm"
|
||||||
@close="showProfileForm = false"
|
:shared-image-url="sharedImageUrl"
|
||||||
|
@close="handleCloseProfileForm"
|
||||||
/>
|
/>
|
||||||
<AuthApplicationsList v-else />
|
<AuthApplicationsList v-else />
|
||||||
|
|
||||||
@@ -51,10 +52,38 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { isAuthenticated } = useAuthentik()
|
const { isAuthenticated } = useAuthentik()
|
||||||
const { isNight } = useTheme()
|
const { isNight } = useTheme()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
// Estado para mostrar formulario de edición
|
// Estado para mostrar formulario de edición
|
||||||
const showProfileForm = ref(false)
|
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
|
// Configurar meta tags para PWA
|
||||||
useHead({
|
useHead({
|
||||||
htmlAttrs: {
|
htmlAttrs: {
|
||||||
|
|||||||
@@ -420,6 +420,11 @@
|
|||||||
const { user } = useAuthentik()
|
const { user } = useAuthentik()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps<{
|
||||||
|
sharedImageUrl?: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
// Emits
|
// Emits
|
||||||
const emit = defineEmits(['close'])
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
@@ -544,6 +549,13 @@ if (import.meta.client) {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
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
|
// Enviar formulario
|
||||||
@@ -802,6 +814,55 @@ const processImageFile = async (file: File) => {
|
|||||||
const blob = new Blob([file], { type: file.type })
|
const blob = new Blob([file], { type: file.type })
|
||||||
await handleAvatarCapture(blob)
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -50,6 +50,22 @@ export default defineNuxtConfig({
|
|||||||
// NOTA: capture_links es experimental pero REQUERIDO para el funcionamiento correcto
|
// NOTA: capture_links es experimental pero REQUERIDO para el funcionamiento correcto
|
||||||
// TypeScript dará error TS2353 pero se ignora intencionalmente (ver README.md)
|
// TypeScript dará error TS2353 pero se ignora intencionalmente (ver README.md)
|
||||||
capture_links: 'existing-client-navigate',
|
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
|
// Extender scope a otros subdominios de Nucleo V3
|
||||||
scope_extensions: [
|
scope_extensions: [
|
||||||
{
|
{
|
||||||
|
|||||||
75
nuxt4/server/api/share-target.post.ts
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
68
nuxt4/server/api/share-target/cleanup.post.ts
Normal 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'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
BIN
pwa_assets/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
pwa_assets/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
pwa_assets/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
pwa_assets/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
pwa_assets/icons/icon-256x256.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
pwa_assets/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
pwa_assets/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 323 KiB |
BIN
pwa_assets/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
pwa_assets/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
30
pwa_assets/index.html
Normal 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
@@ -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
@@ -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;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||