feat: update Angular cache with latest TypeScript definitions
This commit is contained in:
@@ -1,25 +1,27 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
|
||||
export class DmAdapter implements ProviderAdapter {
|
||||
// TODO: Make SearchResult generic
|
||||
export class DmAdapter implements ProviderAdapter<VideoItem> {
|
||||
key = 'dm' as const;
|
||||
label = 'Dailymotion';
|
||||
constructor(private http: HttpClient) {}
|
||||
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'dm' } }));
|
||||
const items = (res?.groups?.dm || []).map((it: any) => ({
|
||||
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
|
||||
let httpParams = new HttpParams().set('q', params.q).set('providers', 'dm');
|
||||
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
||||
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const items: VideoItem[] = (res?.groups?.dm || []).map((it: any) => ({
|
||||
id: it.id,
|
||||
provider: this.key,
|
||||
provider: 'dailymotion',
|
||||
title: it.title,
|
||||
channel: it.uploaderName,
|
||||
channelName: it.uploaderName,
|
||||
durationSec: it.duration,
|
||||
thumbUrl: it.thumbnail,
|
||||
watchUrl: it.url || '',
|
||||
views: undefined,
|
||||
publishedAt: undefined,
|
||||
isLive: it.type === 'live',
|
||||
isShort: it.isShort === true
|
||||
thumbnailUrl: it.thumbnail,
|
||||
viewCount: undefined, // Dailymotion API does not provide view count in search results
|
||||
publishedAt: undefined, // Dailymotion API does not provide published date in search results
|
||||
}));
|
||||
return { items, total: Array.isArray(res?.groups?.dm) ? res.groups.dm.length : 0 };
|
||||
}
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
|
||||
export class OdAdapter implements ProviderAdapter {
|
||||
export class OdAdapter implements ProviderAdapter<VideoItem> {
|
||||
key = 'od' as const;
|
||||
label = 'Odysee';
|
||||
constructor(private http: HttpClient) {}
|
||||
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'od' } }));
|
||||
const items = (res?.groups?.od || []).map((it: any) => ({
|
||||
id: it.id,
|
||||
provider: this.key,
|
||||
title: it.title,
|
||||
channel: it.uploaderName,
|
||||
durationSec: it.duration,
|
||||
thumbUrl: it.thumbnail,
|
||||
watchUrl: it.url || '',
|
||||
views: undefined,
|
||||
publishedAt: undefined,
|
||||
isLive: false,
|
||||
isShort: it.isShort === true
|
||||
}));
|
||||
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
|
||||
let httpParams = new HttpParams().set('q', params.q).set('providers', 'od');
|
||||
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
||||
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const items: VideoItem[] = (res?.groups?.od || []).map((it: any) => {
|
||||
let slug: string | undefined;
|
||||
try {
|
||||
const url: string | undefined = it?.url;
|
||||
if (url && /^https?:\/\/odysee\.com\//i.test(url)) {
|
||||
slug = url.replace(/^https?:\/\/odysee\.com\//i, '').replace(/^\//, '');
|
||||
}
|
||||
} catch {}
|
||||
return {
|
||||
id: it.id,
|
||||
provider: 'odysee',
|
||||
title: it.title,
|
||||
channelName: it.uploaderName,
|
||||
durationSec: it.duration,
|
||||
thumbnailUrl: it.thumbnail,
|
||||
viewCount: undefined,
|
||||
publishedAt: undefined,
|
||||
slug,
|
||||
} as VideoItem;
|
||||
});
|
||||
return { items, total: Array.isArray(res?.groups?.od) ? res.groups.od.length : 0 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
|
||||
export class PtAdapter implements ProviderAdapter {
|
||||
export class PtAdapter implements ProviderAdapter<VideoItem> {
|
||||
key = 'pt' as const;
|
||||
label = 'PeerTube';
|
||||
constructor(private http: HttpClient) {}
|
||||
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'pt' } }));
|
||||
const items = (res?.groups?.pt || []).map((it: any) => ({
|
||||
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
|
||||
let httpParams = new HttpParams().set('q', params.q).set('providers', 'pt');
|
||||
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
||||
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const items: VideoItem[] = (res?.groups?.pt || []).map((it: any) => ({
|
||||
id: it.id,
|
||||
provider: this.key,
|
||||
provider: 'peertube',
|
||||
title: it.title,
|
||||
channel: it.uploaderName,
|
||||
channelName: it.uploaderName,
|
||||
durationSec: it.duration,
|
||||
thumbUrl: it.thumbnail,
|
||||
watchUrl: it.url || '',
|
||||
views: undefined,
|
||||
thumbnailUrl: it.thumbnail,
|
||||
viewCount: undefined,
|
||||
publishedAt: undefined,
|
||||
isLive: false,
|
||||
isShort: false
|
||||
}));
|
||||
return { items, total: Array.isArray(res?.groups?.pt) ? res.groups.pt.length : 0 };
|
||||
}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
|
||||
export class RuAdapter implements ProviderAdapter {
|
||||
export class RuAdapter implements ProviderAdapter<VideoItem> {
|
||||
key = 'ru' as const;
|
||||
label = 'Rumble';
|
||||
constructor(private http: HttpClient) {}
|
||||
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'ru' } }));
|
||||
const items = (res?.groups?.ru || []).map((it: any) => ({
|
||||
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
|
||||
let httpParams = new HttpParams().set('q', params.q).set('providers', 'ru');
|
||||
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
||||
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const items: VideoItem[] = (res?.groups?.ru || []).map((it: any) => ({
|
||||
id: it.id,
|
||||
provider: this.key,
|
||||
provider: 'rumble',
|
||||
title: it.title,
|
||||
channel: it.uploaderName,
|
||||
channelName: it.uploaderName,
|
||||
durationSec: it.duration,
|
||||
thumbUrl: it.thumbnail,
|
||||
watchUrl: it.url || '',
|
||||
views: undefined,
|
||||
publishedAt: undefined,
|
||||
isLive: false,
|
||||
isShort: false
|
||||
thumbnailUrl: it.thumbnail,
|
||||
viewCount: typeof it.views === 'number' ? it.views : undefined,
|
||||
publishedAt: it.publishedAt,
|
||||
}));
|
||||
return { items, total: Array.isArray(res?.groups?.ru) ? res.groups.ru.length : 0 };
|
||||
}
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
|
||||
export class TwAdapter implements ProviderAdapter {
|
||||
export class TwAdapter implements ProviderAdapter<VideoItem> {
|
||||
key = 'tw' as const;
|
||||
label = 'Twitch';
|
||||
constructor(private http: HttpClient) {}
|
||||
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'tw' } }));
|
||||
const items = (res?.groups?.tw || []).map((it: any) => ({
|
||||
id: it.id,
|
||||
provider: this.key,
|
||||
title: it.title,
|
||||
channel: it.uploaderName,
|
||||
durationSec: it.duration,
|
||||
thumbUrl: it.thumbnail,
|
||||
watchUrl: it.url || '',
|
||||
views: undefined,
|
||||
publishedAt: undefined,
|
||||
isLive: it.type === 'live',
|
||||
isShort: false
|
||||
}));
|
||||
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
|
||||
let httpParams = new HttpParams().set('q', params.q).set('providers', 'tw');
|
||||
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
||||
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const items: VideoItem[] = (res?.groups?.tw || []).map((it: any) => {
|
||||
const isChannel = String(it?.type || '').toLowerCase() === 'channel';
|
||||
return {
|
||||
id: it.id,
|
||||
provider: 'twitch',
|
||||
title: it.title,
|
||||
channelName: it.uploaderName,
|
||||
durationSec: it.duration,
|
||||
thumbnailUrl: it.thumbnail,
|
||||
viewCount: typeof it.views === 'number' ? it.views : undefined,
|
||||
publishedAt: it.publishedAt,
|
||||
channel: isChannel ? it.id : undefined,
|
||||
} as VideoItem;
|
||||
});
|
||||
return { items, total: Array.isArray(res?.groups?.tw) ? res.groups.tw.length : 0 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
|
||||
export class YtAdapter implements ProviderAdapter {
|
||||
export class YtAdapter implements ProviderAdapter<VideoItem> {
|
||||
key = 'yt' as const;
|
||||
label = 'YouTube';
|
||||
constructor(private http: HttpClient) {}
|
||||
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'yt' } }));
|
||||
const items = (res?.groups?.yt || []).map((it: any) => ({
|
||||
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
|
||||
let httpParams = new HttpParams().set('q', params.q).set('providers', 'yt');
|
||||
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
||||
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||
const items: VideoItem[] = (res?.groups?.yt || []).map((it: any) => ({
|
||||
id: it.id,
|
||||
provider: this.key,
|
||||
provider: 'youtube',
|
||||
title: it.title,
|
||||
channel: it.uploaderName,
|
||||
channelName: it.uploaderName,
|
||||
durationSec: it.duration,
|
||||
thumbUrl: it.thumbnail,
|
||||
watchUrl: it.url || '',
|
||||
views: undefined,
|
||||
publishedAt: undefined,
|
||||
isLive: it.type === 'live',
|
||||
isShort: it.isShort === true
|
||||
thumbnailUrl: it.thumbnail,
|
||||
viewCount: typeof it.views === 'number' ? it.views : (typeof it.viewCount === 'number' ? it.viewCount : undefined),
|
||||
publishedAt: it.publishedAt,
|
||||
}));
|
||||
return { items, total: Array.isArray(res?.groups?.yt) ? res.groups.yt.length : 0 };
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ export interface SearchItem {
|
||||
watchUrl: string; // internal or external
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
items: SearchItem[];
|
||||
export interface SearchResult<T = SearchItem> {
|
||||
items: T[];
|
||||
total?: number;
|
||||
nextPageToken?: string; // or page: string|number
|
||||
}
|
||||
@@ -29,8 +29,8 @@ export interface ProviderSearchParams {
|
||||
type?: 'video'|'live'|'shorts'|'all';
|
||||
}
|
||||
|
||||
export interface ProviderAdapter {
|
||||
export interface ProviderAdapter<T = SearchItem> {
|
||||
key: ProviderId;
|
||||
label: string;
|
||||
search(params: ProviderSearchParams, signal: AbortSignal): Promise<SearchResult>;
|
||||
search(params: ProviderSearchParams, signal: AbortSignal): Promise<SearchResult<T>>;
|
||||
}
|
||||
|
||||
@@ -10,12 +10,13 @@ import { PtAdapter } from './adapters/pt';
|
||||
import { OdAdapter } from './adapters/od';
|
||||
import { RuAdapter } from './adapters/ru';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from './models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
|
||||
export type SuggestionItem = SuggestionItemV1;
|
||||
export type SearchResponse = SearchResponseV1;
|
||||
|
||||
type CacheKey = string;
|
||||
interface CacheEntry { t: number; data: SearchResult; }
|
||||
interface CacheEntry<T> { t: number; data: SearchResult<T>; }
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SearchService {
|
||||
@@ -29,11 +30,11 @@ export class SearchService {
|
||||
readonly sort$ = new BehaviorSubject<'relevance' | 'date' | 'views' | 'duration'>('relevance');
|
||||
|
||||
// In-memory cache 60s per (provider, q, params)
|
||||
private cache = new Map<CacheKey, CacheEntry>();
|
||||
private cache = new Map<CacheKey, CacheEntry<VideoItem>>();
|
||||
private cacheTtlMs = 60_000;
|
||||
|
||||
// Adapters registry
|
||||
private adapters: Record<ProviderId, ProviderAdapter> = {
|
||||
private adapters: Record<ProviderId, ProviderAdapter<VideoItem>> = {
|
||||
yt: new YtAdapter(this.http),
|
||||
dm: new DmAdapter(this.http),
|
||||
tw: new TwAdapter(this.http),
|
||||
@@ -57,22 +58,13 @@ export class SearchService {
|
||||
// global filter knobs could be added here: time/length/type in the future
|
||||
}, prov)))
|
||||
,
|
||||
// Map normalized items to legacy "groups" response expected by UI
|
||||
// Map provider results to the format expected by the UI
|
||||
map((byProvider) => {
|
||||
const groups = Object.entries(byProvider).reduce((acc, [pid, result]) => {
|
||||
const arr: SuggestionItemV1[] = (result?.items || []).map(it => ({
|
||||
id: it.id,
|
||||
title: it.title,
|
||||
duration: it.durationSec,
|
||||
isShort: it.isShort,
|
||||
thumbnail: it.thumbUrl,
|
||||
uploaderName: it.channel,
|
||||
url: it.watchUrl,
|
||||
type: it.isLive ? 'live' : 'video'
|
||||
}));
|
||||
(acc as any)[pid as ProviderId] = arr;
|
||||
acc[pid as ProviderId] = result.items;
|
||||
return acc;
|
||||
}, {} as Record<ProviderId, SuggestionItemV1[]>);
|
||||
}, {} as Record<ProviderId, VideoItem[]>);
|
||||
|
||||
const providers = Object.keys(byProvider) as ProviderId[];
|
||||
const resp: SearchResponse = { q: this.q$.value, providers, groups };
|
||||
return resp;
|
||||
@@ -81,13 +73,13 @@ export class SearchService {
|
||||
);
|
||||
|
||||
// Orchestrate all active providers in parallel with timeout(8s), retry(1) and abort on param change.
|
||||
private async runAdapters(params: ProviderSearchParams, prov: ProviderId[] | 'all') {
|
||||
private async runAdapters(params: ProviderSearchParams, prov: ProviderId[] | 'all'): Promise<Record<ProviderId, SearchResult<VideoItem>>> {
|
||||
const active: ProviderId[] = prov === 'all' ? ['yt','dm','tw','pt','od','ru'] : (Array.isArray(prov) ? prov : []);
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
const tasks = active.map(async (pid) => {
|
||||
const adapter = this.adapters[pid];
|
||||
if (!adapter) return [pid, { items: [] as any[] } as SearchResult] as const;
|
||||
if (!adapter) return [pid, { items: [] } as SearchResult<VideoItem>] as const;
|
||||
const key: CacheKey = `${pid}|${params.q}|${params.pageToken}|${params.sort}`;
|
||||
const now = Date.now();
|
||||
const cached = this.cache.get(key);
|
||||
@@ -99,9 +91,9 @@ export class SearchService {
|
||||
const tid = setTimeout(() => reject(new Error('timeout')), perProviderTimeout);
|
||||
p.then(v => { clearTimeout(tid); resolve(v); }).catch(e => { clearTimeout(tid); reject(e); });
|
||||
});
|
||||
const attempt = async (): Promise<SearchResult> => withTimeout(adapter.search(params, signal));
|
||||
const attempt = async (): Promise<SearchResult<VideoItem>> => withTimeout(adapter.search(params, signal));
|
||||
try {
|
||||
const res = await attempt().catch(async (e) => {
|
||||
const res: SearchResult<VideoItem> = await attempt().catch(async (e) => {
|
||||
// retry once on network-like errors
|
||||
if (e && (e.name === 'AbortError' || String(e.message || '').includes('abort'))) throw e;
|
||||
try { return await attempt(); } catch (err) { throw err; }
|
||||
@@ -110,11 +102,11 @@ export class SearchService {
|
||||
return [pid, res] as const;
|
||||
} catch {
|
||||
// Swallow errors per provider, return empty
|
||||
return [pid, { items: [] }] as const;
|
||||
return [pid, { items: [] } as SearchResult<VideoItem>] as const;
|
||||
}
|
||||
});
|
||||
const results = await Promise.all(tasks);
|
||||
return Object.fromEntries(results) as Record<ProviderId, SearchResult>;
|
||||
return Object.fromEntries(results) as Record<ProviderId, SearchResult<VideoItem>>;
|
||||
}
|
||||
|
||||
// Convenience setter helpers
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<ng-container *ngIf="loading">
|
||||
<app-video-card-skeleton *ngFor="let _ of skeletonItems"></app-video-card-skeleton>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!loading">
|
||||
<app-video-card *ngFor="let video of videos" [video]="video"></app-video-card>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!loading && videos.length === 0" class="flex flex-col items-center justify-center gap-4 rounded-lg border border-zinc-800 bg-zinc-900/50 p-8 text-center">
|
||||
<h3 class="text-lg font-semibold text-white">Aucun résultat</h3>
|
||||
<p class="text-zinc-400">Essayez une autre recherche ou sélectionnez un autre fournisseur.</p>
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { VideoItem } from '../../models/video-item.model';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { VideoCardComponent } from '../video-card/video-card.component';
|
||||
import { VideoCardSkeletonComponent } from '../video-card-skeleton/video-card-skeleton.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-search-result-grid',
|
||||
templateUrl: './search-result-grid.component.html',
|
||||
styleUrls: ['./search-result-grid.component.scss'],
|
||||
standalone: true,
|
||||
imports: [CommonModule, VideoCardComponent, VideoCardSkeletonComponent],
|
||||
})
|
||||
export class SearchResultGridComponent {
|
||||
@Input() videos: VideoItem[] = [];
|
||||
@Input() loading = false;
|
||||
@Input() skeletons = 12;
|
||||
|
||||
get skeletonItems() {
|
||||
return new Array(this.skeletons);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<div class="flex flex-col gap-2 animate-pulse">
|
||||
<div class="aspect-video w-full rounded-lg bg-zinc-800"></div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="h-5 w-3/4 rounded bg-zinc-800"></div>
|
||||
<div class="h-4 w-1/2 rounded bg-zinc-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-video-card-skeleton',
|
||||
templateUrl: './video-card-skeleton.component.html',
|
||||
styleUrls: ['./video-card-skeleton.component.scss'],
|
||||
standalone: true,
|
||||
})
|
||||
export class VideoCardSkeletonComponent {}
|
||||
@@ -0,0 +1,21 @@
|
||||
<a [routerLink]="['/watch', video.id]" [queryParams]="buildQueryParams(video)" [state]="{ video }" class="group flex flex-col gap-2">
|
||||
<div class="relative aspect-video w-full overflow-hidden rounded-lg bg-zinc-800">
|
||||
<img [src]="video.thumbnailUrl" [alt]="video.title" class="h-full w-full object-cover transition-transform group-hover:scale-105" />
|
||||
<span *ngIf="video.durationSec" class="absolute bottom-1 right-1 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
|
||||
{{ video.durationSec | date:'mm:ss' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<h3 class="text-base font-semibold text-zinc-100 line-clamp-2" [title]="video.title">
|
||||
{{ video.title }}
|
||||
</h3>
|
||||
<div class="text-sm text-zinc-400">
|
||||
<p *ngIf="video.channelName" class="font-medium">{{ video.channelName }}</p>
|
||||
<div class="flex gap-2">
|
||||
<span *ngIf="video.viewCount">{{ video.viewCount | number }} vues</span>
|
||||
<span *ngIf="video.publishedAt">{{ video.publishedAt | date:'shortDate' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -0,0 +1,7 @@
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { VideoItem } from '../../models/video-item.model';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-video-card',
|
||||
templateUrl: './video-card.component.html',
|
||||
styleUrls: ['./video-card.component.scss'],
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
})
|
||||
export class VideoCardComponent {
|
||||
@Input() video!: VideoItem;
|
||||
|
||||
buildQueryParams(v: VideoItem): Record<string, any> {
|
||||
const qp: any = { p: v.provider };
|
||||
if (v.provider === 'odysee' && v.slug) qp.slug = v.slug;
|
||||
if (v.provider === 'twitch' && v.channel) qp.channel = v.channel;
|
||||
return qp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface VideoItem {
|
||||
id: string; // id vidéo canonicalisé
|
||||
provider: 'youtube' | 'dailymotion' | 'twitch' | 'peertube' | 'odysee' | 'rumble';
|
||||
title: string;
|
||||
thumbnailUrl: string;
|
||||
durationSec?: number;
|
||||
channelName?: string;
|
||||
viewCount?: number;
|
||||
publishedAt?: string; // ISO
|
||||
// Provider-specific routing hints
|
||||
slug?: string; // Odysee slug when available
|
||||
channel?: string; // Twitch channel when item represents a channel/live
|
||||
}
|
||||
Reference in New Issue
Block a user