feat(oauth): import favoris/abos Google (YouTube) et Twitch vers abonnements/likes
- Backend: server/oauth.mjs (auth URL, echange code, refresh, fetch
subscriptions/likes Google, follows Twitch), table oauth_connections
+ migration, routes /api/oauth/:provider/{url,callback,preview,import},
/api/oauth/{status,connections}
- Front: /library/import (connect, preview, import), service
oauth-import, lien depuis Abonnements
- Config: GOOGLE_CLIENT_ID/SECRET/REDIRECT_URI, TWITCH_REDIRECT_URI,
OAUTH_APP_BASE_URL documentes (.env.example, docker-compose),
passthrough docker-compose.local.yml, roadmap README cochee
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
<div class="container mx-auto p-4 sm:p-6">
|
||||
<div class="mb-6 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<a [routerLink]="['/library/subscriptions']" class="rounded-lg border border-slate-700 bg-slate-800 px-3 py-1.5 text-slate-200 transition hover:bg-slate-700">‹</a>
|
||||
<h2 class="text-3xl font-bold text-slate-100">Importer depuis Google / Twitch</h2>
|
||||
</div>
|
||||
<p class="text-sm text-slate-400">Connectez votre compte, prévisualisez, puis importez vos abonnements et favoris dans NewTube.</p>
|
||||
<div class="flex gap-2 text-sm">
|
||||
<a [routerLink]="['/library/subscriptions']" class="text-red-300 hover:text-red-200">Voir mes abonnements</a>
|
||||
<span class="text-slate-600">•</span>
|
||||
<a [routerLink]="['/library/liked']" class="text-red-300 hover:text-red-200">Voir mes favoris</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="notice()" class="mb-4 rounded-lg border border-blue-700 bg-blue-900/30 p-3 text-sm text-blue-200">{{ notice() }}</div>
|
||||
<div *ngIf="loading()" class="text-sm text-slate-400">Chargement…</div>
|
||||
|
||||
<div *ngIf="!loading()" class="grid gap-4 md:grid-cols-2">
|
||||
<div *ngFor="let p of providers" class="rounded-xl border border-slate-800 bg-slate-900/60 p-4">
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<h3 class="text-lg font-semibold text-slate-100"><span class="mr-2">{{ p.emoji }}</span>{{ p.label }}</h3>
|
||||
<span *ngIf="isConnected(p.id)" class="rounded-full bg-green-900/40 border border-green-700 px-2 py-0.5 text-xs text-green-200">connecté</span>
|
||||
<span *ngIf="!isConnected(p.id)" class="rounded-full bg-slate-800 border border-slate-700 px-2 py-0.5 text-xs text-slate-300">non connecté</span>
|
||||
</div>
|
||||
<p class="mb-3 text-sm text-slate-400">{{ p.hint }}</p>
|
||||
|
||||
<div *ngIf="!configured()[p.id]" class="mb-3 rounded-lg border border-amber-700 bg-amber-900/20 p-2 text-xs text-amber-200">
|
||||
Non configuré côté serveur. Ajoutez {{ (missing()[p.id] || []).join(', ') || 'les variables manquantes' }} puis redémarrez l’API.
|
||||
<span *ngIf="p.id === 'google'">Voir <code>GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / GOOGLE_REDIRECT_URI</code> dans <code>.env.example</code>.</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="isConnected(p.id) && connectionOf(p.id) as c" class="mb-3 flex items-center gap-2 text-sm text-slate-300">
|
||||
<img *ngIf="c.avatarUrl" [src]="c.avatarUrl" alt="" class="h-8 w-8 rounded-full object-cover" />
|
||||
<span class="font-medium">{{ c.displayName || c.externalUserId }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex flex-wrap gap-2">
|
||||
<button *ngIf="!isConnected(p.id)" type="button" [disabled]="!configured()[p.id]"
|
||||
(click)="connect(p.id)"
|
||||
class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-red-500 disabled:opacity-40">
|
||||
Connecter {{ p.label }}
|
||||
</button>
|
||||
<button *ngIf="isConnected(p.id)" type="button" (click)="preview(p.id)" [disabled]="loadingPreview()[p.id]"
|
||||
class="rounded-md border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm text-slate-200 transition hover:border-slate-500">
|
||||
{{ loadingPreview()[p.id] ? 'Aperçu…' : 'Prévisualiser' }}
|
||||
</button>
|
||||
<button *ngIf="isConnected(p.id)" type="button" (click)="importAll(p.id)" [disabled]="importing()[p.id]"
|
||||
class="rounded-md bg-green-700 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-green-600 disabled:opacity-40">
|
||||
{{ importing()[p.id] ? 'Import…' : 'Importer' }}
|
||||
</button>
|
||||
<button *ngIf="isConnected(p.id)" type="button" (click)="disconnect(p.id)"
|
||||
class="rounded-md border border-slate-700 bg-transparent px-3 py-1.5 text-sm text-slate-400 transition hover:text-white">
|
||||
Déconnecter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="errors()[p.id]" class="mb-2 rounded-lg border border-red-700 bg-red-900/30 p-2 text-xs text-red-200">{{ errors()[p.id] }}</div>
|
||||
|
||||
<div *ngIf="previews()[p.id] as pv" class="mb-2 text-sm">
|
||||
<p class="text-slate-300">Abonnements : <strong>{{ pv.subscriptionCount }}</strong><span *ngIf="p.id === 'google'"> • Favoris : <strong>{{ pv.likeCount }}</strong></span></p>
|
||||
<ul class="mt-2 max-h-48 space-y-1 overflow-auto text-xs text-slate-400">
|
||||
<li *ngFor="let s of pv.subscriptions.slice(0, 20)" class="flex items-center gap-2">
|
||||
<img *ngIf="s.avatarUrl" [src]="s.avatarUrl" alt="" class="h-5 w-5 rounded-full object-cover" />
|
||||
<span class="truncate">{{ s.title }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ul *ngIf="pv.likes?.length" class="mt-2 max-h-32 space-y-1 overflow-auto text-xs text-slate-400">
|
||||
<li *ngFor="let l of pv.likes.slice(0, 10)" class="truncate">♥ {{ l.title }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div *ngIf="results()[p.id] as r" class="rounded-lg border border-green-700 bg-green-900/20 p-2 text-xs text-green-200">
|
||||
Importé : {{ r.importedSubscriptions }} abonnement(s)<span *ngIf="p.id === 'google'">, {{ r.importedLikes }} favori(s)</span>.
|
||||
<span *ngIf="r.skippedSubscriptions"> (ignorés : {{ r.skippedSubscriptions }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,116 @@
|
||||
import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { OAuthImportService, type OAuthConnection, type OAuthPreview, type OAuthImportResult } from '../../../services/oauth-import.service';
|
||||
|
||||
interface ProviderCard {
|
||||
id: 'google' | 'twitch';
|
||||
label: string;
|
||||
emoji: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-library-import',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
templateUrl: './import.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ImportComponent implements OnInit {
|
||||
private api = inject(OAuthImportService);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
readonly providers: ProviderCard[] = [
|
||||
{ id: 'google', label: 'Google / YouTube', emoji: '🔴', hint: 'Abonnements + favoris (likes YouTube)' },
|
||||
{ id: 'twitch', label: 'Twitch', emoji: '🟣', hint: 'Chaînes suivies (pas de likes côté Twitch)' },
|
||||
];
|
||||
|
||||
configured = signal<Record<string, boolean>>({ google: false, twitch: false });
|
||||
missing = signal<Record<string, string[]>>({ google: [], twitch: [] });
|
||||
connections = signal<OAuthConnection[]>([]);
|
||||
previews = signal<Record<string, OAuthPreview | null>>({});
|
||||
loadingPreview = signal<Record<string, boolean>>({});
|
||||
importing = signal<Record<string, boolean>>({});
|
||||
results = signal<Record<string, OAuthImportResult | null>>({});
|
||||
errors = signal<Record<string, string | null>>({});
|
||||
notice = signal<string | null>(null);
|
||||
loading = signal(true);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.queryParamMap.subscribe((pm) => {
|
||||
const connected = pm.get('connected');
|
||||
const provider = pm.get('provider');
|
||||
const error = pm.get('error');
|
||||
if (connected && provider) this.notice.set(`Connecté à ${provider} — prévisualisez puis importez.`);
|
||||
if (error) this.notice.set(`Échec OAuth (${provider || ''}) : ${error}`);
|
||||
});
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.loading.set(true);
|
||||
this.api.connections().subscribe({
|
||||
next: (res) => {
|
||||
this.connections.set(res?.connections || []);
|
||||
const st = res?.status;
|
||||
if (st) {
|
||||
this.configured.set({ google: !!st.google?.configured, twitch: !!st.twitch?.configured });
|
||||
this.missing.set({ google: st.google?.missing || [], twitch: st.twitch?.missing || [] });
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => this.loading.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
isConnected(id: string): boolean {
|
||||
return this.connections().some((c) => c.provider === id);
|
||||
}
|
||||
|
||||
connectionOf(id: string): OAuthConnection | undefined {
|
||||
return this.connections().find((c) => c.provider === id);
|
||||
}
|
||||
|
||||
connect(id: 'google' | 'twitch'): void {
|
||||
this.errors.set({ ...this.errors(), [id]: null });
|
||||
this.api.connect(id).catch((e) => {
|
||||
this.errors.set({ ...this.errors(), [id]: e?.error?.error || e?.message || 'oauth_url_failed' });
|
||||
});
|
||||
}
|
||||
|
||||
disconnect(id: string): void {
|
||||
this.api.disconnect(id).subscribe({ next: () => this.refresh() });
|
||||
}
|
||||
|
||||
preview(id: string): void {
|
||||
this.loadingPreview.set({ ...this.loadingPreview(), [id]: true });
|
||||
this.errors.set({ ...this.errors(), [id]: null });
|
||||
this.api.preview(id).subscribe({
|
||||
next: (p) => {
|
||||
this.previews.set({ ...this.previews(), [id]: p });
|
||||
this.loadingPreview.set({ ...this.loadingPreview(), [id]: false });
|
||||
},
|
||||
error: (e) => {
|
||||
this.errors.set({ ...this.errors(), [id]: e?.error?.error || e?.message || 'oauth_preview_failed' });
|
||||
this.loadingPreview.set({ ...this.loadingPreview(), [id]: false });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
importAll(id: string): void {
|
||||
const types = id === 'twitch' ? ['subscriptions'] : ['subscriptions', 'likes'];
|
||||
this.importing.set({ ...this.importing(), [id]: true });
|
||||
this.errors.set({ ...this.errors(), [id]: null });
|
||||
this.api.importAll(id, types).subscribe({
|
||||
next: (r) => {
|
||||
this.results.set({ ...this.results(), [id]: r });
|
||||
this.importing.set({ ...this.importing(), [id]: false });
|
||||
},
|
||||
error: (e) => {
|
||||
this.errors.set({ ...this.errors(), [id]: e?.error?.error || e?.message || 'oauth_import_failed' });
|
||||
this.importing.set({ ...this.importing(), [id]: false });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,12 @@
|
||||
>
|
||||
Rafraîchir
|
||||
</button>
|
||||
<a
|
||||
[routerLink]="['/library/import']"
|
||||
class="rounded-md border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm text-slate-200 transition hover:border-slate-500 hover:text-white"
|
||||
>
|
||||
⬇ Importer (Google / Twitch)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user