feat: enhance webhook event handling and improve profile fetching logic
- Updated the WebhooksService to differentiate between 'InvoiceSettled' and 'InvoicePaymentSettled' events, ensuring accurate payment confirmation and logging. - Enhanced the useAccounts composable to improve display name handling by skipping generic placeholder names and ensuring the most recent profile metadata is fetched from multiple relays. - Modified the IndeehubApiService to handle JWT token refresh failures gracefully, allowing public endpoints to function without authentication. - Updated content store logic to fetch all published projects from the public API, ensuring backstage content is visible to all users regardless of their active content source. These changes improve the reliability of payment processing, enhance user profile representation, and ensure content visibility across the application.
This commit is contained in:
@@ -63,8 +63,12 @@ export class WebhooksService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'InvoiceSettled':
|
// Only process InvoiceSettled — this fires when the invoice is
|
||||||
case 'InvoicePaymentSettled': {
|
// confirmed paid. InvoicePaymentSettled fires earlier (when
|
||||||
|
// payment is received but not yet confirmed) and can cause
|
||||||
|
// "Invoice not paid" errors due to the invoice still being
|
||||||
|
// in Processing state.
|
||||||
|
case 'InvoiceSettled': {
|
||||||
try {
|
try {
|
||||||
const invoiceId = event.invoiceId;
|
const invoiceId = event.invoiceId;
|
||||||
const invoice = await this.btcpayService.getInvoice(invoiceId);
|
const invoice = await this.btcpayService.getInvoice(invoiceId);
|
||||||
@@ -97,6 +101,16 @@ export class WebhooksService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'InvoicePaymentSettled': {
|
||||||
|
// Payment received but invoice may still be processing.
|
||||||
|
// We wait for InvoiceSettled to confirm the payment.
|
||||||
|
Logger.log(
|
||||||
|
`Invoice ${event.invoiceId} payment received — waiting for InvoiceSettled to confirm`,
|
||||||
|
'WebhooksService',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'InvoiceExpired':
|
case 'InvoiceExpired':
|
||||||
case 'InvoiceInvalid': {
|
case 'InvoiceInvalid': {
|
||||||
Logger.log(
|
Logger.log(
|
||||||
|
|||||||
@@ -686,6 +686,9 @@ function formatTime(seconds: number): string {
|
|||||||
background: #0a0a0a;
|
background: #0a0a0a;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh; /* dynamic viewport height for mobile */
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player-container {
|
.video-player-container {
|
||||||
@@ -694,6 +697,7 @@ function formatTime(seconds: number): string {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: none;
|
cursor: none;
|
||||||
|
min-height: 0; /* prevent video intrinsic size from overflowing */
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player-container:hover {
|
.video-player-container:hover {
|
||||||
@@ -734,10 +738,13 @@ function formatTime(seconds: number): string {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-height: 0; /* prevent video intrinsic size from overflowing flex container */
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-area video {
|
.video-area video {
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
|
max-height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* YouTube iframe fills the entire area */
|
/* YouTube iframe fills the entire area */
|
||||||
|
|||||||
@@ -57,15 +57,21 @@ export function useAccounts() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display name: prefer Nostr profile → test persona → pubkey slice.
|
* Display name: prefer Nostr profile → test persona → npub slice.
|
||||||
|
* Skips generic placeholder names like "Nostr" so the user sees
|
||||||
|
* a more useful identifier while they set up their profile.
|
||||||
*/
|
*/
|
||||||
const activeName = computed(() => {
|
const activeName = computed(() => {
|
||||||
if (!activeAccount.value) return null
|
if (!activeAccount.value) return null
|
||||||
|
|
||||||
// First: check if we have kind 0 profile metadata
|
// First: check if we have kind 0 profile metadata
|
||||||
const profile = activeProfile.value
|
const profile = activeProfile.value
|
||||||
if (profile?.display_name) return profile.display_name
|
if (profile?.display_name && !/^nostr$/i.test(profile.display_name.trim())) {
|
||||||
if (profile?.name) return profile.name
|
return profile.display_name
|
||||||
|
}
|
||||||
|
if (profile?.name && !/^nostr$/i.test(profile.name.trim())) {
|
||||||
|
return profile.name
|
||||||
|
}
|
||||||
|
|
||||||
// Second: check test personas
|
// Second: check test personas
|
||||||
const allPersonas: Persona[] = [
|
const allPersonas: Persona[] = [
|
||||||
@@ -77,8 +83,10 @@ export function useAccounts() {
|
|||||||
)
|
)
|
||||||
if (match?.name) return match.name
|
if (match?.name) return match.name
|
||||||
|
|
||||||
// Fallback: first 8 chars of pubkey
|
// Fallback: truncated npub for a friendlier display
|
||||||
return activeAccount.value?.pubkey?.slice(0, 8) ?? 'Unknown'
|
const pk = activeAccount.value?.pubkey
|
||||||
|
if (!pk) return 'Unknown'
|
||||||
|
return `npub...${pk.slice(-6)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,11 +101,15 @@ export function useAccounts() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch kind 0 profile metadata for a pubkey from relays.
|
* Fetch kind 0 profile metadata for a pubkey from relays.
|
||||||
* Results are cached to avoid redundant relay queries.
|
* Queries multiple relays and keeps the MOST RECENT event
|
||||||
|
* (highest created_at) to ensure we get the latest profile.
|
||||||
*/
|
*/
|
||||||
function fetchProfile(pubkey: string) {
|
function fetchProfile(pubkey: string) {
|
||||||
if (!pubkey || profileCache.value.has(pubkey)) return
|
if (!pubkey || profileCache.value.has(pubkey)) return
|
||||||
|
|
||||||
|
// Track the best (most recent) event seen so far for this fetch
|
||||||
|
let bestCreatedAt = 0
|
||||||
|
|
||||||
const relays = [...APP_RELAYS, ...LOOKUP_RELAYS]
|
const relays = [...APP_RELAYS, ...LOOKUP_RELAYS]
|
||||||
try {
|
try {
|
||||||
const profileSub = pool
|
const profileSub = pool
|
||||||
@@ -108,18 +120,34 @@ export function useAccounts() {
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: (event: any) => {
|
next: (event: any) => {
|
||||||
try {
|
try {
|
||||||
|
// Only accept this event if it's newer than what we already have
|
||||||
|
const eventTime = event.created_at ?? 0
|
||||||
|
if (eventTime < bestCreatedAt) return
|
||||||
|
|
||||||
const metadata: NostrProfileMetadata = JSON.parse(event.content)
|
const metadata: NostrProfileMetadata = JSON.parse(event.content)
|
||||||
const updated = new Map(profileCache.value)
|
|
||||||
updated.set(pubkey, metadata)
|
// Skip profiles with no meaningful data (empty or
|
||||||
profileCache.value = updated
|
// generic placeholder names without a picture)
|
||||||
|
const hasName = !!(metadata.display_name || metadata.name)
|
||||||
|
const isGeneric = hasName && /^nostr$/i.test((metadata.display_name || metadata.name || '').trim())
|
||||||
|
const hasPicture = !!metadata.picture
|
||||||
|
|
||||||
|
// Accept if: has a non-generic name, has a picture, or
|
||||||
|
// this is the first event we've received
|
||||||
|
if (bestCreatedAt === 0 || !isGeneric || hasPicture) {
|
||||||
|
bestCreatedAt = eventTime
|
||||||
|
const updated = new Map(profileCache.value)
|
||||||
|
updated.set(pubkey, metadata)
|
||||||
|
profileCache.value = updated
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid JSON in profile event
|
// Invalid JSON in profile event
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Close subscription after 5 seconds
|
// Close subscription after 8 seconds (allows slower relays to respond)
|
||||||
setTimeout(() => profileSub.unsubscribe(), 5000)
|
setTimeout(() => profileSub.unsubscribe(), 8000)
|
||||||
subscriptions.push(profileSub)
|
subscriptions.push(profileSub)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[useAccounts] Failed to fetch profile for ${pubkey}:`, err)
|
console.error(`[useAccounts] Failed to fetch profile for ${pubkey}:`, err)
|
||||||
|
|||||||
@@ -31,8 +31,15 @@ function getAppRelays(): string[] {
|
|||||||
// App relays (local dev relay or proxied production relay)
|
// App relays (local dev relay or proxied production relay)
|
||||||
export const APP_RELAYS = getAppRelays()
|
export const APP_RELAYS = getAppRelays()
|
||||||
|
|
||||||
// Lookup relays for profile metadata
|
// Lookup relays for profile metadata (kind 0, kind 3, NIP-65).
|
||||||
export const LOOKUP_RELAYS = ['wss://purplepag.es']
|
// Multiple popular relays ensures we find the user's most up-to-date profile.
|
||||||
|
export const LOOKUP_RELAYS = [
|
||||||
|
'wss://purplepag.es',
|
||||||
|
'wss://relay.damus.io',
|
||||||
|
'wss://relay.nostr.band',
|
||||||
|
'wss://nos.lol',
|
||||||
|
'wss://relay.primal.net',
|
||||||
|
]
|
||||||
|
|
||||||
// Observable relay list for reactive subscriptions
|
// Observable relay list for reactive subscriptions
|
||||||
export const appRelays = new BehaviorSubject<string[]>([...APP_RELAYS])
|
export const appRelays = new BehaviorSubject<string[]>([...APP_RELAYS])
|
||||||
|
|||||||
@@ -22,13 +22,18 @@ class IndeehubApiService {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Attach JWT token from NIP-98 session.
|
// Attach JWT token from NIP-98 session if available.
|
||||||
// If the token has expired but we have a refresh token, proactively
|
// Refresh failures are caught so public endpoints still work
|
||||||
// refresh before sending the request to avoid unnecessary 401 round-trips.
|
// without auth (e.g. GET /projects after switching users).
|
||||||
this.client.interceptors.request.use(async (config) => {
|
this.client.interceptors.request.use(async (config) => {
|
||||||
let token = nip98Service.accessToken
|
let token = nip98Service.accessToken
|
||||||
if (!token && sessionStorage.getItem('indeehub_api_refresh')) {
|
if (!token && sessionStorage.getItem('indeehub_api_refresh')) {
|
||||||
token = await nip98Service.refresh()
|
try {
|
||||||
|
token = await nip98Service.refresh()
|
||||||
|
} catch {
|
||||||
|
// Refresh failed (stale token, wrong user, etc.)
|
||||||
|
// Continue without auth — public endpoints don't need it
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
@@ -37,17 +42,23 @@ class IndeehubApiService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Auto-refresh on 401 (fallback if the proactive refresh above
|
// Auto-refresh on 401 (fallback if the proactive refresh above
|
||||||
// didn't happen or the token expired mid-flight)
|
// didn't happen or the token expired mid-flight).
|
||||||
|
// Refresh failures are caught so the original error propagates
|
||||||
|
// cleanly instead of masking it with a refresh error.
|
||||||
this.client.interceptors.response.use(
|
this.client.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error) => {
|
async (error) => {
|
||||||
const originalRequest = error.config
|
const originalRequest = error.config
|
||||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
originalRequest._retry = true
|
originalRequest._retry = true
|
||||||
const newToken = await nip98Service.refresh()
|
try {
|
||||||
if (newToken) {
|
const newToken = await nip98Service.refresh()
|
||||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
if (newToken) {
|
||||||
return this.client(originalRequest)
|
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||||
|
return this.client(originalRequest)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Refresh failed — fall through to reject the original error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
@@ -100,7 +111,10 @@ class IndeehubApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all published projects
|
* Get all published projects.
|
||||||
|
* Pass `fresh: true` to bypass the backend's 5-minute cache
|
||||||
|
* (e.g. after login or content source switch) so newly published
|
||||||
|
* backstage content appears immediately.
|
||||||
*/
|
*/
|
||||||
async getProjects(filters?: {
|
async getProjects(filters?: {
|
||||||
status?: string
|
status?: string
|
||||||
@@ -108,6 +122,7 @@ class IndeehubApiService {
|
|||||||
genre?: string
|
genre?: string
|
||||||
limit?: number
|
limit?: number
|
||||||
offset?: number
|
offset?: number
|
||||||
|
fresh?: boolean
|
||||||
}): Promise<any[]> {
|
}): Promise<any[]> {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (filters?.status) params.append('status', filters.status)
|
if (filters?.status) params.append('status', filters.status)
|
||||||
@@ -115,6 +130,7 @@ class IndeehubApiService {
|
|||||||
if (filters?.genre) params.append('genre', filters.genre)
|
if (filters?.genre) params.append('genre', filters.genre)
|
||||||
if (filters?.limit) params.append('limit', String(filters.limit))
|
if (filters?.limit) params.append('limit', String(filters.limit))
|
||||||
if (filters?.offset) params.append('offset', String(filters.offset))
|
if (filters?.offset) params.append('offset', String(filters.offset))
|
||||||
|
if (filters?.fresh) params.append('_t', String(Date.now()))
|
||||||
|
|
||||||
const url = `/projects${params.toString() ? `?${params.toString()}` : ''}`
|
const url = `/projects${params.toString() ? `?${params.toString()}` : ''}`
|
||||||
const response = await this.client.get(url)
|
const response = await this.client.get(url)
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { contentService } from '../services/content.service'
|
|||||||
import { mapApiProjectsToContents } from '../utils/mappers'
|
import { mapApiProjectsToContents } from '../utils/mappers'
|
||||||
import { useContentSourceStore } from './contentSource'
|
import { useContentSourceStore } from './contentSource'
|
||||||
import { indeehubApiService } from '../services/indeehub-api.service'
|
import { indeehubApiService } from '../services/indeehub-api.service'
|
||||||
import { useFilmmaker } from '../composables/useFilmmaker'
|
// useFilmmaker import removed — mergePublishedFilmmakerProjects
|
||||||
|
// now fetches from the public API so all users see backstage content
|
||||||
import { USE_MOCK as USE_MOCK_DATA } from '../utils/mock'
|
import { USE_MOCK as USE_MOCK_DATA } from '../utils/mock'
|
||||||
|
|
||||||
export const useContentStore = defineStore('content', () => {
|
export const useContentStore = defineStore('content', () => {
|
||||||
@@ -73,7 +74,7 @@ export const useContentStore = defineStore('content', () => {
|
|||||||
*/
|
*/
|
||||||
async function fetchContentFromIndeehubApi() {
|
async function fetchContentFromIndeehubApi() {
|
||||||
try {
|
try {
|
||||||
const response = await indeehubApiService.getProjects()
|
const response = await indeehubApiService.getProjects({ fresh: true })
|
||||||
|
|
||||||
// Handle both array responses and wrapped responses like { data: [...] }
|
// Handle both array responses and wrapped responses like { data: [...] }
|
||||||
const projects = Array.isArray(response) ? response : (response as any)?.data ?? []
|
const projects = Array.isArray(response) ? response : (response as any)?.data ?? []
|
||||||
@@ -181,77 +182,67 @@ export const useContentStore = defineStore('content', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert published filmmaker projects to Content format and merge
|
* Fetch ALL published projects from the public API and merge them
|
||||||
* them into the existing content rows so they appear on the browse page.
|
* into the existing content rows so backstage content appears for
|
||||||
|
* every user, regardless of which content source is active.
|
||||||
*
|
*
|
||||||
* If the filmmaker projects haven't been loaded yet (e.g. first visit
|
* Ownership is determined by comparing the project's filmmaker ID
|
||||||
* is the homepage, not backstage), attempt to fetch them first — but
|
* to the current user's filmmaker profile. Only the actual creator
|
||||||
* only when the user has an active session.
|
* sees the "my movie" flag — not other logged-in users.
|
||||||
*/
|
*/
|
||||||
async function mergePublishedFilmmakerProjects() {
|
async function mergePublishedFilmmakerProjects() {
|
||||||
|
const apiUrl = import.meta.env.VITE_INDEEHUB_API_URL || ''
|
||||||
|
if (!apiUrl) return // No backend configured
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const filmmaker = useFilmmaker()
|
// Fetch ALL published projects from the public API endpoint.
|
||||||
|
// This works for any user (authenticated or not) — no auth required.
|
||||||
|
// Use fresh: true to bypass the backend's 5-minute cache
|
||||||
|
// so newly published backstage content appears immediately.
|
||||||
|
const response = await indeehubApiService.getProjects({ fresh: true })
|
||||||
|
const projects = Array.isArray(response) ? response : (response as any)?.data ?? []
|
||||||
|
if (!Array.isArray(projects) || projects.length === 0) return
|
||||||
|
|
||||||
// If projects aren't loaded yet, check whether the user has
|
// Determine the current user's filmmaker ID (if any) for
|
||||||
// session tokens. We check sessionStorage directly because
|
// ownership tagging. Import lazily to avoid circular deps.
|
||||||
// authStore.initialize() may still be in-flight on first load.
|
let currentFilmmakerId: string | null = null
|
||||||
if (filmmaker.projects.value.length === 0) {
|
try {
|
||||||
const nostrToken = sessionStorage.getItem('nostr_token')
|
const { useAuthStore } = await import('./auth')
|
||||||
const cognitoToken = sessionStorage.getItem('auth_token')
|
const authStore = useAuthStore()
|
||||||
const hasSession = !!nostrToken || !!cognitoToken
|
currentFilmmakerId = authStore.user?.filmmaker?.id ?? null
|
||||||
|
} catch {
|
||||||
if (hasSession) {
|
// Auth store not ready — ownership will be unknown
|
||||||
// Always sync nip98Service tokens on page load. The token
|
|
||||||
// may exist in sessionStorage but its expiry record may be
|
|
||||||
// stale, causing nip98Service.accessToken to return null.
|
|
||||||
// Re-storing refreshes the expiry so the interceptor can
|
|
||||||
// include the token; if it really expired the backend 401
|
|
||||||
// interceptor will auto-refresh via the refresh token.
|
|
||||||
if (nostrToken) {
|
|
||||||
const { nip98Service } = await import('../services/nip98.service')
|
|
||||||
nip98Service.storeTokens(
|
|
||||||
nostrToken,
|
|
||||||
sessionStorage.getItem('indeehub_api_refresh') ?? sessionStorage.getItem('refresh_token') ?? '',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await filmmaker.fetchProjects()
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[content-store] Failed to fetch filmmaker projects for merge:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const published = filmmaker.projects.value.filter(p => p.status === 'published')
|
const publishedContent: Content[] = projects.map((p: any) => {
|
||||||
if (published.length === 0) return
|
const projectFilmmakerId = p.filmmaker?.id ?? p.filmmakerId ?? null
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
contentId: p.film?.id,
|
||||||
|
title: p.title || p.name,
|
||||||
|
description: p.synopsis || '',
|
||||||
|
thumbnail: p.poster || '/images/placeholder-poster.jpg',
|
||||||
|
backdrop: p.poster || '/images/placeholder-poster.jpg',
|
||||||
|
type: p.type === 'episodic' ? 'series' as const : 'film' as const,
|
||||||
|
rating: p.format || undefined,
|
||||||
|
releaseYear: p.releaseDate ? new Date(p.releaseDate).getFullYear() : new Date().getFullYear(),
|
||||||
|
categories: p.genres?.map((g: any) => g.name) || [],
|
||||||
|
slug: p.slug,
|
||||||
|
rentalPrice: p.film?.rentalPrice ?? p.rentalPrice,
|
||||||
|
status: p.status,
|
||||||
|
apiData: p,
|
||||||
|
// Only mark as "own project" if the current user is the actual filmmaker
|
||||||
|
isOwnProject: !!(currentFilmmakerId && projectFilmmakerId && currentFilmmakerId === projectFilmmakerId),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
console.debug(
|
console.debug(
|
||||||
'[content-store] Merging published projects:',
|
'[content-store] Merging API published projects:',
|
||||||
published.map(p => ({ id: p.id, title: p.title, filmId: p.film?.id, rentalPrice: p.film?.rentalPrice ?? p.rentalPrice })),
|
publishedContent.map(c => ({ id: c.id, title: c.title, isOwn: c.isOwnProject })),
|
||||||
)
|
)
|
||||||
|
|
||||||
const publishedContent: Content[] = published.map(p => ({
|
|
||||||
id: p.id,
|
|
||||||
contentId: p.film?.id,
|
|
||||||
title: p.title || p.name,
|
|
||||||
description: p.synopsis || '',
|
|
||||||
thumbnail: p.poster || '/images/placeholder-poster.jpg',
|
|
||||||
backdrop: p.poster || '/images/placeholder-poster.jpg',
|
|
||||||
type: p.type === 'episodic' ? 'series' as const : 'film' as const,
|
|
||||||
rating: p.format || undefined,
|
|
||||||
releaseYear: p.releaseDate ? new Date(p.releaseDate).getFullYear() : new Date().getFullYear(),
|
|
||||||
categories: p.genres?.map(g => g.name) || [],
|
|
||||||
slug: p.slug,
|
|
||||||
rentalPrice: p.film?.rentalPrice ?? p.rentalPrice,
|
|
||||||
status: p.status,
|
|
||||||
apiData: p,
|
|
||||||
isOwnProject: true,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Merge into each content row (prepend so they appear first)
|
// Merge into each content row (prepend so they appear first)
|
||||||
for (const key of Object.keys(contentRows.value)) {
|
for (const key of Object.keys(contentRows.value)) {
|
||||||
// Avoid duplicates by filtering out any already-present IDs
|
|
||||||
const existingIds = new Set(contentRows.value[key].map(c => c.id))
|
const existingIds = new Set(contentRows.value[key].map(c => c.id))
|
||||||
const newItems = publishedContent.filter(c => !existingIds.has(c.id))
|
const newItems = publishedContent.filter(c => !existingIds.has(c.id))
|
||||||
if (newItems.length > 0) {
|
if (newItems.length > 0) {
|
||||||
@@ -263,8 +254,8 @@ export const useContentStore = defineStore('content', () => {
|
|||||||
if (!featuredContent.value && publishedContent.length > 0) {
|
if (!featuredContent.value && publishedContent.length > 0) {
|
||||||
featuredContent.value = publishedContent[0]
|
featuredContent.value = publishedContent[0]
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Filmmaker composable may not be initialized yet — safe to ignore
|
console.warn('[content-store] Failed to merge API published projects:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user