first commit
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
<div class="container mx-auto p-4 sm:p-6 max-w-6xl">
|
||||
<h2 class="text-3xl font-bold mb-8 text-slate-100 border-l-4 border-red-500 pl-4 flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Historique
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Search History Section -->
|
||||
<section class="bg-slate-800/50 rounded-lg p-6 shadow-lg">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-slate-200 flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
Historique des recherches
|
||||
</h3>
|
||||
<span class="text-xs bg-slate-700 text-slate-300 px-2 py-1 rounded-full">
|
||||
{{ searchHistory().length }} recherche(s)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="searchHistory().length === 0" class="text-slate-400 text-center py-4">
|
||||
Aucune recherche récente
|
||||
</div>
|
||||
|
||||
<ul class="space-y-3">
|
||||
<li *ngFor="let s of searchHistory()"
|
||||
class="group bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
|
||||
<a [routerLink]="['/search']" [queryParams]="{ q: s.query, provider: getSearchProvider(s).id }"
|
||||
class="block p-4 hover:no-underline">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<div class="text-slate-100 font-medium group-hover:text-blue-400 transition-colors">
|
||||
{{ s.query }}
|
||||
</div>
|
||||
<div class="flex items-center mt-1 text-xs text-slate-400">
|
||||
<span class="inline-flex items-center bg-slate-800/80 px-2 py-0.5 rounded mr-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
{{ getSearchProvider(s).name }}
|
||||
</span>
|
||||
<span class="text-slate-500">
|
||||
{{ formatDate(s.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="text-slate-400 hover:text-blue-400 p-1 -mr-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Watch History Section -->
|
||||
<section class="bg-slate-800/50 rounded-lg p-6 shadow-lg">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-slate-200 flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Historique de visionnage
|
||||
</h3>
|
||||
<span class="text-xs bg-slate-700 text-slate-300 px-2 py-1 rounded-full">
|
||||
{{ watchHistory().length }} vidéo(s)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="watchHistory().length === 0" class="text-slate-400 text-center py-4">
|
||||
Aucune vidéo récemment regardée
|
||||
</div>
|
||||
|
||||
<ul class="space-y-3">
|
||||
<li *ngFor="let w of watchHistory()"
|
||||
class="group bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
|
||||
<a [routerLink]="['/watch', w.video_id]" [queryParams]="{ p: w.provider }"
|
||||
class="block p-4 hover:no-underline">
|
||||
<div class="flex">
|
||||
<div class="relative flex-shrink-0 w-24 h-16 bg-slate-700 rounded overflow-hidden flex items-center justify-center">
|
||||
<div class="text-slate-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 right-0 h-1 bg-slate-600">
|
||||
<div class="h-full bg-red-500" [style.width.%]="getProgressPercentage(w)"></div>
|
||||
</div>
|
||||
<div *ngIf="w.duration_seconds" class="absolute bottom-1 right-1 bg-black/80 text-white text-2xs px-1 rounded">
|
||||
{{ formatDuration(w.duration_seconds) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4 flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-slate-100 group-hover:text-blue-400 transition-colors truncate">
|
||||
{{ w.title || 'Sans titre' }}
|
||||
</h4>
|
||||
<div class="mt-1 text-xs text-slate-400">
|
||||
<span class="inline-flex items-center bg-slate-800/80 px-2 py-0.5 rounded mr-2">
|
||||
{{ w.provider }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 text-2xs text-slate-500">
|
||||
<span>Regardé le {{ formatDate(w.watched_at) }}</span>
|
||||
<span class="mx-2">•</span>
|
||||
<span>Progression: {{ formatDuration(w.progress_seconds || 0) }} / {{ formatDuration(w.duration_seconds || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="text-slate-400 hover:text-blue-400 p-1 -mr-1 self-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,139 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { HistoryService, SearchHistoryItem, WatchHistoryItem } from '../../../services/history.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-history',
|
||||
standalone: true,
|
||||
templateUrl: './history.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, RouterModule]
|
||||
})
|
||||
export class HistoryComponent {
|
||||
private history = inject(HistoryService);
|
||||
|
||||
loading = signal<boolean>(false);
|
||||
searchHistory = signal<SearchHistoryItem[]>([]);
|
||||
watchHistory = signal<WatchHistoryItem[]>([]);
|
||||
|
||||
constructor() {
|
||||
this.reload();
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.loading.set(true);
|
||||
this.history.getSearchHistory(50).subscribe({
|
||||
next: (items) => this.searchHistory.set(items || []),
|
||||
error: () => {},
|
||||
});
|
||||
this.history.getWatchHistory(50).subscribe({
|
||||
next: (items) => this.watchHistory.set(items || []),
|
||||
error: () => {},
|
||||
});
|
||||
this.loading.set(false);
|
||||
}
|
||||
|
||||
// Format a date to a readable string
|
||||
formatDate(dateString: string | Date): string {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// Get provider from search filters or query params
|
||||
getSearchProvider(item: SearchHistoryItem): { name: string; id: string } {
|
||||
// Try to get provider from filters first
|
||||
if (item.filters_json) {
|
||||
try {
|
||||
const filters = JSON.parse(item.filters_json);
|
||||
if (filters.provider) {
|
||||
const providerName = this.formatProviderName(filters.provider);
|
||||
return { name: providerName, id: filters.provider };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing filters_json:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// If no provider in filters, try to extract from URL query params
|
||||
try {
|
||||
const url = new URL(item.query);
|
||||
const provider = url.searchParams.get('provider');
|
||||
if (provider) {
|
||||
const providerName = this.formatProviderName(provider);
|
||||
return { name: providerName, id: provider };
|
||||
}
|
||||
} catch (e) {
|
||||
// Not a valid URL or no provider in query params
|
||||
}
|
||||
|
||||
// Default to the selected provider if available
|
||||
const defaultProvider = this.getDefaultProvider();
|
||||
if (defaultProvider) {
|
||||
return { name: this.formatProviderName(defaultProvider), id: defaultProvider };
|
||||
}
|
||||
|
||||
// Fallback to 'all' if no provider can be determined
|
||||
return { name: 'Tous', id: 'all' };
|
||||
}
|
||||
|
||||
// Get the default provider from the current URL or settings
|
||||
private getDefaultProvider(): string | null {
|
||||
try {
|
||||
// Try to get from current URL
|
||||
const url = new URL(window.location.href);
|
||||
const provider = url.searchParams.get('provider');
|
||||
if (provider) return provider;
|
||||
|
||||
// Or get from localStorage if available
|
||||
const savedProvider = localStorage.getItem('selectedProvider');
|
||||
if (savedProvider) return savedProvider;
|
||||
|
||||
// Default to youtube if nothing else
|
||||
return 'youtube';
|
||||
} catch (e) {
|
||||
return 'youtube'; // Fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Format provider ID to a display name
|
||||
private formatProviderName(providerId: string): string {
|
||||
const providerMap: { [key: string]: string } = {
|
||||
'youtube': 'YouTube',
|
||||
'vimeo': 'Vimeo',
|
||||
'dailymotion': 'Dailymotion',
|
||||
'peertube': 'PeerTube',
|
||||
'rumble': 'Rumble'
|
||||
};
|
||||
return providerMap[providerId.toLowerCase()] || providerId;
|
||||
}
|
||||
|
||||
// Format seconds to HH:MM:SS or MM:SS
|
||||
formatDuration(seconds: number): string {
|
||||
if (!seconds && seconds !== 0) return '00:00';
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
} else {
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate watch progress percentage
|
||||
getProgressPercentage(item: WatchHistoryItem): number {
|
||||
if (!item.duration_seconds || item.duration_seconds === 0) return 0;
|
||||
const progress = (item.progress_seconds || 0) / item.duration_seconds;
|
||||
return Math.min(100, Math.max(0, progress * 100));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<div class="container mx-auto p-4 sm:p-6">
|
||||
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">{{ 'preferences.title' | t }}</h2>
|
||||
|
||||
<!-- Notices -->
|
||||
<div *ngIf="notice()" class="mb-4 bg-emerald-900/30 border border-emerald-600 text-emerald-200 px-4 py-3 rounded">{{ notice() }}</div>
|
||||
<div *ngIf="error()" class="mb-4 bg-red-900/30 border border-red-600 text-red-200 px-4 py-3 rounded">{{ error() }}</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<!-- General -->
|
||||
<form (submit)="onSave($event)" class="space-y-6">
|
||||
<div class="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-100">{{ 'preferences.general' | t : 'Préférences générales' }}</h3>
|
||||
<p class="text-sm text-slate-400">{{ 'preferences.general_hint' | t : 'Personnalisez votre expérience' }}</p>
|
||||
</div>
|
||||
<button [disabled]="saving()" class="px-4 py-2 rounded bg-red-600 hover:bg-red-500 disabled:opacity-50">{{ 'btn.save' | t : 'Enregistrer' }}</button>
|
||||
</div>
|
||||
<div class="p-5 grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label class="block text-sm text-slate-400 mb-1">{{ 'label.language' | t : 'Langue' }}</label>
|
||||
<select class="w-full rounded bg-slate-900 text-slate-100 border border-slate-700 px-3 py-2" [ngModel]="language()" (ngModelChange)="language.set($event)" name="language">
|
||||
<option value="en">English</option>
|
||||
<option value="fr">Français</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-slate-400 mb-1">{{ 'label.defaultProvider' | t : 'Fournisseur par défaut' }}</label>
|
||||
<select class="w-full rounded bg-slate-900 text-slate-100 border border-slate-700 px-3 py-2" [ngModel]="defaultProvider()" (ngModelChange)="defaultProvider.set($event)" name="provider">
|
||||
<option *ngFor="let p of providers()" [value]="p.id">{{ p.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-slate-400 mb-1">{{ 'label.theme' | t : 'Thème' }}</label>
|
||||
<select class="w-full rounded bg-slate-900 text-slate-100 border border-slate-700 px-3 py-2" [ngModel]="theme()" (ngModelChange)="theme.set($event)" name="theme">
|
||||
<option *ngFor="let t of themes" [value]="t">{{ t | titlecase }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-slate-400 mb-1">{{ 'label.quality' | t : 'Qualité par défaut' }}</label>
|
||||
<select class="w-full rounded bg-slate-900 text-slate-100 border border-slate-700 px-3 py-2" [ngModel]="videoQuality()" (ngModelChange)="videoQuality.set($event)" name="quality">
|
||||
<option *ngFor="let q of qualities" [value]="q">{{ q }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-slate-400 mb-1">{{ 'label.region' | t : 'Région' }}</label>
|
||||
<select class="w-full rounded bg-slate-900 text-slate-100 border border-slate-700 px-3 py-2" [ngModel]="region()" (ngModelChange)="region.set($event)" name="region">
|
||||
<option *ngFor="let r of regions" [value]="r">{{ ('region.' + r) | t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- PeerTube Instances (below general) -->
|
||||
<section class="space-y-4">
|
||||
<div class="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-slate-700">
|
||||
<h3 class="text-lg font-semibold text-slate-100">PeerTube</h3>
|
||||
<p class="text-sm text-slate-400">{{ 'preferences.peertube_hint' | t : 'Gérez vos instances PeerTube' }}</p>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<form (submit)="addPeerTubeInstance($event)" class="flex items-center gap-3">
|
||||
<input type="text" name="peertube"
|
||||
class="flex-1 rounded bg-slate-900 text-slate-100 border border-slate-700 px-3 py-2"
|
||||
[placeholder]="'preferences.add_instance_placeholder' | t : 'ex: example.org'"
|
||||
[ngModel]="newPeerTubeInstance()" (ngModelChange)="newPeerTubeInstance.set($event)" />
|
||||
<button type="submit" class="px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-white">{{ 'btn.add' | t : 'Ajouter' }}</button>
|
||||
</form>
|
||||
|
||||
<div *ngIf="(peerTubeInstances() || []).length === 0" class="text-sm text-slate-400">{{ 'preferences.no_instances' | t : 'Aucune instance pour le moment.' }}</div>
|
||||
|
||||
<ul class="divide-y divide-slate-700 rounded border border-slate-700 overflow-hidden">
|
||||
<li *ngFor="let host of peerTubeInstances()" class="flex items-center justify-between px-4 py-2 bg-slate-900">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-block w-2 h-2 rounded-full"
|
||||
[ngClass]="{ 'bg-emerald-400': activePeerTubeInstance() === host, 'bg-slate-600': activePeerTubeInstance() !== host }"></span>
|
||||
<span class="text-slate-200">{{ host }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" (click)="setActivePeerTube(host)"
|
||||
class="px-2 py-1 rounded border"
|
||||
[ngClass]="{ 'border-emerald-500 text-emerald-300': activePeerTubeInstance() === host, 'border-slate-600 text-slate-300 hover:bg-slate-800': activePeerTubeInstance() !== host }">
|
||||
{{ activePeerTubeInstance() === host ? ('preferences.active' | t : 'Actif') : ('preferences.set_active' | t : 'Définir actif') }}
|
||||
</button>
|
||||
<button type="button" (click)="removePeerTubeInstance(host)" class="px-2 py-1 rounded bg-red-600 hover:bg-red-500 text-white">{{ 'btn.remove' | t : 'Supprimer' }}</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { UserService, UserPreferences } from '../../../services/user.service';
|
||||
import { InstanceService, Provider } from '../../../services/instance.service';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { I18nService } from '../../../services/i18n.service';
|
||||
import { TranslatePipe } from '../../../pipes/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-preferences',
|
||||
standalone: true,
|
||||
templateUrl: './preferences.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, FormsModule, TranslatePipe]
|
||||
})
|
||||
export class PreferencesComponent {
|
||||
private user = inject(UserService);
|
||||
instances = inject(InstanceService);
|
||||
private i18n = inject(I18nService);
|
||||
|
||||
// Form model
|
||||
language = signal<string>('en');
|
||||
defaultProvider = signal<Provider>('youtube');
|
||||
theme = signal<string>('system');
|
||||
videoQuality = signal<string>('auto');
|
||||
region = signal<string>('US');
|
||||
|
||||
saving = signal<boolean>(false);
|
||||
notice = signal<string | null>(null);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
providers = this.instances.providers; // signal of ProviderInfo[]
|
||||
regions = ['US', 'FR', 'BR', 'DE', 'GB', 'CA'];
|
||||
themes = ['system', 'light', 'dark', 'black', 'blue'];
|
||||
qualities = ['auto', '720p', '1080p'];
|
||||
|
||||
// PeerTube instances management
|
||||
newPeerTubeInstance = signal<string>('');
|
||||
peerTubeInstances = this.instances.peerTubeInstances; // signal<string[]>
|
||||
activePeerTubeInstance = this.instances.activePeerTubeInstance; // signal<string>
|
||||
|
||||
constructor() {
|
||||
// Load current preferences into form
|
||||
firstValueFrom(this.user.loadPreferences()).then((prefs) => this.applyPrefs(prefs)).catch(() => {});
|
||||
}
|
||||
|
||||
private applyPrefs(p: UserPreferences | null) {
|
||||
if (!p) return;
|
||||
if (p.language) this.language.set(p.language);
|
||||
// apply language immediately
|
||||
if (p.language) this.i18n.setLanguage(p.language);
|
||||
if (p.defaultProvider) this.defaultProvider.set(p.defaultProvider as Provider);
|
||||
if (p.theme) this.theme.set(p.theme);
|
||||
if (p.videoQuality) this.videoQuality.set(p.videoQuality);
|
||||
if (p.region) this.region.set(p.region);
|
||||
// Apply theme immediately on load
|
||||
try {
|
||||
document.documentElement.setAttribute('data-theme', p.theme || 'system');
|
||||
localStorage.setItem('newtube.theme', p.theme || 'system');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async onSave(ev: Event) {
|
||||
ev.preventDefault();
|
||||
this.saving.set(true);
|
||||
this.notice.set(null);
|
||||
try {
|
||||
const patch: Partial<UserPreferences> = {
|
||||
language: this.language(),
|
||||
defaultProvider: this.defaultProvider(),
|
||||
theme: this.theme(),
|
||||
videoQuality: this.videoQuality(),
|
||||
region: this.region(),
|
||||
};
|
||||
const prefs = await firstValueFrom(this.user.updatePreferences(patch));
|
||||
// Also apply immediately to runtime (provider/region/theme)
|
||||
if (prefs.defaultProvider) this.instances.setSelectedProvider(prefs.defaultProvider as Provider);
|
||||
if (prefs.region) this.instances.setRegion(prefs.region);
|
||||
if (prefs.language) this.i18n.setLanguage(prefs.language);
|
||||
try {
|
||||
document.documentElement.setAttribute('data-theme', prefs.theme || 'system');
|
||||
localStorage.setItem('newtube.theme', prefs.theme || 'system');
|
||||
} catch {}
|
||||
this.notice.set('Preferences saved.');
|
||||
} catch {
|
||||
this.notice.set('Failed to save preferences.');
|
||||
} finally {
|
||||
this.saving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- PeerTube instances helpers ----
|
||||
private isValidDomain(host: string): boolean {
|
||||
const v = (host || '').trim().toLowerCase();
|
||||
// Basic domain validation (no protocol, no path)
|
||||
return !!v && /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(v) && !/^https?:\/\//i.test(v) && !/[\/\s]/.test(v);
|
||||
}
|
||||
|
||||
addPeerTubeInstance(ev?: Event) {
|
||||
ev?.preventDefault();
|
||||
this.error.set(null);
|
||||
const raw = (this.newPeerTubeInstance() || '').trim();
|
||||
if (!this.isValidDomain(raw)) {
|
||||
this.error.set('Enter a valid domain like "example.org" (no http://, no path).');
|
||||
return;
|
||||
}
|
||||
const domain = raw.toLowerCase();
|
||||
const list = [...(this.peerTubeInstances() || [])];
|
||||
if (list.includes(domain)) {
|
||||
this.error.set('This instance is already in the list.');
|
||||
return;
|
||||
}
|
||||
list.push(domain);
|
||||
this.instances.setPeerTubeInstances(list);
|
||||
this.newPeerTubeInstance.set('');
|
||||
this.notice.set('PeerTube instance added.');
|
||||
}
|
||||
|
||||
removePeerTubeInstance(domain: string) {
|
||||
const list = (this.peerTubeInstances() || []).filter(d => d !== domain);
|
||||
this.instances.setPeerTubeInstances(list);
|
||||
if (this.activePeerTubeInstance() === domain) {
|
||||
const next = list[0] || '';
|
||||
if (next) this.instances.setActivePeerTubeInstance(next);
|
||||
}
|
||||
this.notice.set('PeerTube instance removed.');
|
||||
}
|
||||
|
||||
setActivePeerTube(domain: string) {
|
||||
this.instances.setActivePeerTubeInstance(domain);
|
||||
this.notice.set(`Active PeerTube instance set to ${domain}.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<div class="container mx-auto p-4 sm:p-6">
|
||||
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">Sessions</h2>
|
||||
|
||||
<div *ngIf="error()" class="mb-4 bg-amber-900/40 border border-amber-600 text-amber-200 p-3 rounded">{{ error() }}</div>
|
||||
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<button class="px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-white" (click)="reload()" [disabled]="loading()">Reload</button>
|
||||
<button class="px-4 py-2 rounded bg-red-600 hover:bg-red-500 text-white" (click)="logoutAll()" [disabled]="loading()">Logout all devices</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="loading()" class="text-slate-400">Loading...</div>
|
||||
|
||||
<div *ngIf="!loading() && items().length === 0" class="text-slate-400">No active sessions.</div>
|
||||
|
||||
<ul class="space-y-3">
|
||||
<li *ngFor="let s of items()" class="p-4 bg-slate-800 rounded flex items-start justify-between gap-4">
|
||||
<div class="text-sm text-slate-200">
|
||||
<div><span class="text-slate-400">Session ID:</span> <code>{{ s.id }}</code></div>
|
||||
<div><span class="text-slate-400">User Agent:</span> {{ s.userAgent || '—' }}</div>
|
||||
<div><span class="text-slate-400">Device:</span> {{ s.deviceInfo || '—' }}</div>
|
||||
<div><span class="text-slate-400">IP:</span> {{ s.ip || '—' }}</div>
|
||||
<div><span class="text-slate-400">Remember me:</span> {{ s.isRemember ? 'Yes' : 'No' }}</div>
|
||||
<div><span class="text-slate-400">Created:</span> {{ s.createdAt || '—' }}</div>
|
||||
<div><span class="text-slate-400">Last seen:</span> {{ s.lastSeenAt || '—' }}</div>
|
||||
<div><span class="text-slate-400">Expires:</span> {{ s.expiresAt || '—' }}</div>
|
||||
<div *ngIf="s.revokedAt"><span class="text-slate-400">Revoked:</span> {{ s.revokedAt }}</div>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<button class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-white" (click)="revoke(s.id)">Revoke</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AuthService, SessionInfo } from '../../../services/auth.service';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sessions',
|
||||
standalone: true,
|
||||
templateUrl: './sessions.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule]
|
||||
})
|
||||
export class SessionsComponent {
|
||||
private auth = inject(AuthService);
|
||||
|
||||
loading = signal<boolean>(false);
|
||||
items = signal<SessionInfo[]>([]);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
this.reload();
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.auth.listSessions().subscribe({
|
||||
next: (list) => {
|
||||
this.items.set(list || []);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (e: any) => {
|
||||
const status = (e && typeof e.status === 'number') ? e.status : 0;
|
||||
if (status === 401) {
|
||||
this.error.set('Please login to view sessions.');
|
||||
} else {
|
||||
this.error.set('Failed to load sessions.');
|
||||
}
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async revoke(id: string) {
|
||||
try {
|
||||
await firstValueFrom(this.auth.revokeSession(id));
|
||||
this.items.update(arr => arr.filter(s => s.id !== id));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async logoutAll() {
|
||||
try {
|
||||
await firstValueFrom(this.auth.logout(true));
|
||||
this.items.set([]);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user