feat(channel): identite chaine partagee cliquable partout + logos fiabilises
CI / build-and-test (push) Successful in 13m25s

- Nouveau app-channel-identity (avatar+nom -> /channel, fallback resolve /api/channels, initiales sinon)
- ProviderBadge: accepte les ids courts yt/dm/tw/pt/od/ru
- Search adapters yt/dm/pt/od: conservent channelExternalId/channelAvatarUrl (pt derive instance|channel)
- video-card/themes/watch/related/suggestions/subscriptions/channel-header uniformises sur l'identite partagee
This commit is contained in:
2026-09-25 10:37:36 -04:00
parent 97e5de74f5
commit 579354cdb3
20 changed files with 355 additions and 159 deletions
@@ -19,9 +19,15 @@
@if (item.thumbnail) {
<img [src]="item.thumbnail" class="h-12 w-20 object-cover rounded" alt=""/>
}
<div class="min-w-0">
<div class="min-w-0 flex-1">
<div class="text-slate-100 truncate">{{ item.title }}</div>
<div class="text-slate-400 text-xs">{{ item.uploaderName }}</div>
<app-channel-identity
size="sm"
[provider]="s.id"
[externalId]="channelExternalId(s, item)"
[avatarUrl]="channelAvatar(item)"
[name]="item.uploaderName || null"
></app-channel-identity>
</div>
</li>
}
@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { PROVIDERS, PROVIDER_MAP, type ProviderId } from '../../app/core/providers/provider-registry';
import type { SuggestionItem } from '../../app/search/search.service';
import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component';
interface SuggestionSection {
id: ProviderId;
@@ -17,7 +18,7 @@ interface SuggestionSection {
@Component({
selector: 'app-search-suggestions',
standalone: true,
imports: [CommonModule, RouterLink],
imports: [CommonModule, RouterLink, ChannelIdentityComponent],
templateUrl: './search-suggestions.component.html'
})
export class SearchSuggestionsComponent {
@@ -29,8 +30,7 @@ export class SearchSuggestionsComponent {
// which unsupported providers get an explicit row (Step 6)
@Input() activeProviders: ProviderId[] | 'all' = 'all';
readonly sections = computed<SuggestionSection[]>(() => {
const groups = this.groups || ({} as Record<ProviderId, SuggestionItem[]>);
readonly sections = computed<SuggestionSection[]>(() => { const groups = this.groups || ({} as Record<ProviderId, SuggestionItem[]>);
const active = this.activeProviders === 'all'
? PROVIDERS.map(p => p.id)
: (this.activeProviders || []).filter(id => PROVIDER_MAP.has(id));
@@ -47,4 +47,30 @@ export class SearchSuggestionsComponent {
} as SuggestionSection;
});
});
/** ExternalId chaîne pour le lien /channel (null = non cliquable). */
channelExternalId(section: { id: ProviderId }, item: SuggestionItem): string | null {
const raw = item as unknown as Record<string, unknown>;
if (section.id === 'pt') {
// PeerTube : `instance|channel`, instance dérivée de l'URL vidéo.
try {
const url = String(raw?.['url'] || '');
const host = url ? new URL(url).hostname : '';
const channel = String(raw?.['channelId'] || '');
if (host && channel) return `${host}|${channel}`;
} catch {}
return null;
}
if (section.id === 'od') {
const name = String(raw?.['uploaderName'] || '');
return name || null;
}
const ext = String(raw?.['channelExternalId'] || raw?.['channelId'] || '');
return ext || null;
}
channelAvatar(item: SuggestionItem): string | null {
const raw = item as unknown as Record<string, unknown>;
return String(raw?.['uploaderAvatar'] || '') || null;
}
}