first commit

This commit is contained in:
2025-09-14 23:05:30 -04:00
commit 2866d74b32
122 changed files with 95474 additions and 0 deletions
@@ -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 {}
}
}