feat(mobile): refonte responsive complète (header, drawer, dvh, cibles tactiles)
Le header était en grid-cols-[auto_1fr_auto] avec une barre de recherche non-wrappable (7 chips + input + 2 boutons, aucun min-w-0) : ~1000px de largeur intrinsèque dans un viewport de 375px. Toutes les routes débordaient horizontalement et les boutons Connexion/Inscription partaient hors écran. Socle - --header-h / --header-offset / --app-vh centralisent la métrique du header : supprime les top-16 codés en dur (channel-tabs était en top-0 et glissait sous le header au scroll). - Safe-area iOS, viewport-fit=cover, color-scheme/theme-color. - dvh partout (100vh > hauteur visible sur iOS), overflow-x: clip au lieu de hidden (hidden cassait silencieusement tous les sticky). - Cible tactile 44px sur pointeur grossier, skip link, focus-ring clavier, scroll-rail pour les tab strips. Header - Barre mobile (burger, logo, recherche, menu ⋮) + barre desktop inchangée. - Le menu ⋮ expose ce qui disparaissait : bande des thèmes, préférences, historique, favoris, téléchargements, thème, connexion/inscription. - Recherche en feuille sous le header avec focus automatique. - Échap / clic extérieur / navigation ferment les panneaux transitoires. Drawer - Partait à inset-y-0 : les 56 premiers px du menu étaient derrière le header. Descend sous le header, bouton fermer intégré, verrouille le scroll, se ferme à Échap. Barre de recherche - Chips masquées sous lg, remplacées par un bouton résumé qui ouvre le sélecteur de fournisseurs. Suggestions en dvh, menu Ctrl+K plein écran sur mobile. Pages - Shorts : cadre 9:16 contraint par dvh ET vw (il débordait en paysage), Préc./Suiv. qui étaient hors du conteneur height-capped. - Watch : barre d'actions qui wrappe, mode cinéma en opacity-0 group-hover donc invisible au doigt, thumbnails fluides, playsinline sur les iframes. - Historique, thèmes, chaîne, playlists, préférences, sessions : rails swipables, min-w-0, truncate, en-têtes qui wrappent. Vérifié : audit navigateur (CDP) sur 17 routes x 7 largeurs (320→1440), zéro débordement horizontal ; 19 tests d'interaction mobile ; suites de tests du dépôt vertes (api 40/40, transcript 27/27, search, suggest, playlists, preferences, subscriptions, highlight, telemetry).
This commit is contained in:
@@ -1,23 +1,260 @@
|
||||
/* Minimal stylesheet to accompany Tailwind CDN styles */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
/* --- Layout metrics -----------------------------------------------------
|
||||
Single source of truth for the app chrome height so templates never have
|
||||
to hardcode `top-16` / `h-[calc(100vh-4rem)]` (which silently break when the
|
||||
header wraps on narrow viewports). */
|
||||
--header-h: 3.5rem; /* 56px — mobile */
|
||||
--safe-top: 0px;
|
||||
--safe-bottom: 0px;
|
||||
--safe-left: 0px;
|
||||
--safe-right: 0px;
|
||||
/* Height actually consumed by the fixed header, safe-area included. */
|
||||
--header-offset: calc(var(--header-h) + var(--safe-top));
|
||||
/* Usable viewport height that follows the mobile browser chrome. */
|
||||
--app-vh: 100vh;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
:root { --app-vh: 100dvh; }
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
:root { --header-h: 4rem; } /* 64px — desktop */
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
/* `clip` (not `hidden`) so the element never becomes a scroll container —
|
||||
`overflow: hidden` on <html> silently breaks every `position: sticky`
|
||||
child, including the header rails. */
|
||||
overflow-x: clip;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
@supports not (overflow: clip) {
|
||||
html { overflow-x: hidden; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
*, *::before, *::after {
|
||||
animation-duration: .01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: .01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: var(--app-vh);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans,
|
||||
"Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
background-color: #0f172a; /* slate-900 */
|
||||
color: #e2e8f0; /* slate-200 */
|
||||
/* No grey flash / no accidental text selection while tapping chrome. */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
/* Container helper to match Tailwind's container defaults a bit closer */
|
||||
/* Inputs on iOS render at 16px by default which triggers focus zoom; make sure
|
||||
no field ever drops below that on small viewports. Buttons are excluded so
|
||||
compact `text-xs` toolbars keep their compact look. */
|
||||
@media (max-width: 767px) {
|
||||
input:not([type="checkbox"]):not([type="radio"]),
|
||||
select,
|
||||
textarea {
|
||||
font-size: max(16px, 1em);
|
||||
}
|
||||
/* Never let a flex/grid child blow out the viewport. */
|
||||
img, svg, video, iframe {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Touch ergonomics ----------------------------------------------------- */
|
||||
/* WCAG 2.5.5 / Apple HIG: 44px is the comfortable minimum on coarse pointers.
|
||||
Desktop keeps the compact visual sizing. */
|
||||
@media (pointer: coarse) {
|
||||
button,
|
||||
[role="button"],
|
||||
a.chip {
|
||||
min-height: 44px;
|
||||
}
|
||||
/* Icon-only controls (no text child) get a square minimum too. */
|
||||
button.icon-only,
|
||||
[role="button"].icon-only {
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
.chip {
|
||||
transition: background-color .15s ease, color .15s ease, opacity .15s ease;
|
||||
}
|
||||
|
||||
/* Expand tiny hit areas without changing visual size. */
|
||||
.hit-expand { position: relative; }
|
||||
.hit-expand::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* --- Mobile primitives ---------------------------------------------------- */
|
||||
/* Horizontal swipe rail: the standard mobile pattern for tab strips that would
|
||||
otherwise wrap or get clipped. */
|
||||
.scroll-rail {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
|
||||
/* Scroll affordance for swipe rails that hide their scrollbar. */
|
||||
.scroll-rail-fade {
|
||||
position: relative;
|
||||
}
|
||||
.scroll-rail-fade::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 2rem;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to right, transparent, var(--bg));
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Vertical stacks inside dialogs must be able to scroll within dvh. */
|
||||
.dialog-scroll {
|
||||
max-height: min(85dvh, 100%);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* Prevent long unbroken strings (UUIDs, URLs, tokens) from breaking layout. */
|
||||
.break-anywhere { overflow-wrap: anywhere; word-break: break-word; }
|
||||
|
||||
/* Focus ring for keyboard users only (touch devices get :focus instead). */
|
||||
.focus-ring:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* --- App chrome primitives ------------------------------------------------ */
|
||||
/* Icon-only header/button. 40px on pointer devices, 44px on touch. */
|
||||
.app-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.5rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.375rem;
|
||||
border-radius: 9999px;
|
||||
color: var(--text);
|
||||
transition: background-color .15s ease, opacity .15s ease;
|
||||
}
|
||||
.app-icon-btn:hover { background-color: var(--panel-2); }
|
||||
.app-icon-btn:active { transform: scale(.94); }
|
||||
|
||||
/* Avatar trigger (always >= 44px of tappable area). */
|
||||
.app-avatar-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem;
|
||||
border-radius: 9999px;
|
||||
transition: background-color .15s ease;
|
||||
}
|
||||
.app-avatar-btn:hover { background-color: var(--panel-2); }
|
||||
|
||||
.avatar-circle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 9999px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* The fixed app header: honours the safe area and never overflows. */
|
||||
.app-header {
|
||||
/* Nothing inside may widen the bar. */
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
/* --- Quick menu / command palette (Ctrl+K) -------------------------------- */
|
||||
.quick-menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background-color: rgba(0, 0, 0, .6);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: max(1rem, env(safe-area-inset-top)) 1rem;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.quick-menu-dialog {
|
||||
margin-top: 5vh;
|
||||
width: 100%;
|
||||
max-width: 56rem;
|
||||
max-height: min(85dvh, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: .75rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, .25);
|
||||
background-color: var(--bg, #0f172a);
|
||||
border: 1px solid var(--border, #334155);
|
||||
}
|
||||
@supports not (height: 100dvh) {
|
||||
.quick-menu-dialog { max-height: 85vh; }
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
/* On phones the dialog owns the viewport: no floating "modal" look. */
|
||||
.quick-menu-backdrop { padding: 0; }
|
||||
.quick-menu-dialog {
|
||||
margin-top: 0;
|
||||
max-height: 100dvh;
|
||||
height: 100dvh;
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Container helper: responsive gutters + fluid cap (Tailwind's CDN `container`
|
||||
is pinned to a single width, which breaks small viewports). */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.container { padding-inline: 1.5rem; }
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.container { max-width: 1280px; padding-inline: 2rem; }
|
||||
}
|
||||
|
||||
/* --- Theme System ------------------------------------------------------- */
|
||||
@@ -232,3 +469,51 @@ body {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sidebar-scroll { scroll-behavior: auto; }
|
||||
}
|
||||
|
||||
/* --- Shorts player frame --------------------------------------------------- */
|
||||
/* 9:16 frame constrained by BOTH the dynamic viewport height and the viewport
|
||||
width, so it never overflows a portrait phone nor a landscape one.
|
||||
`--shorts-chrome` reserves room for the provider rail + bottom navigation. */
|
||||
.shorts-frame {
|
||||
--shorts-chrome: 11rem;
|
||||
aspect-ratio: 9 / 16;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: min(
|
||||
calc(var(--app-vh) - var(--header-offset) - var(--shorts-chrome)),
|
||||
calc((100vw - 2rem) * 16 / 9)
|
||||
);
|
||||
min-height: 0;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.shorts-frame {
|
||||
--shorts-chrome: 9rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Drawer / overlay plumbing -------------------------------------------- */
|
||||
/* Applied to <body> while a full-screen drawer or modal owns the viewport.
|
||||
Note: deliberately NOT `touch-action: none` — touch-action is intersected up
|
||||
the ancestor chain, so it would also freeze scrolling *inside* the drawer. */
|
||||
body.scroll-locked {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Skip link: visible only on keyboard focus. */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
top: -4rem;
|
||||
z-index: 100;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
transition: top .15s ease;
|
||||
}
|
||||
.skip-link:focus {
|
||||
top: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
+14
-1
@@ -2,7 +2,10 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<title>NewTube</title>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
@@ -33,6 +36,16 @@
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #6c7086;
|
||||
}
|
||||
|
||||
/* Shift the fixed header below the iOS status bar when in standalone mode. */
|
||||
@supports (padding: env(safe-area-inset-top)) {
|
||||
html {
|
||||
--safe-top: env(safe-area-inset-top);
|
||||
--safe-bottom: env(safe-area-inset-bottom);
|
||||
--safe-left: env(safe-area-inset-left);
|
||||
--safe-right: env(safe-area-inset-right);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
|
||||
+22
-10
@@ -1,27 +1,39 @@
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<app-header (menuToggle)="toggleSidebar()"></app-header>
|
||||
<div class="pt-16">
|
||||
<a href="#main-content" class="skip-link">{{ 'common.skipToContent' | t }}</a>
|
||||
|
||||
<div class="flex min-h-dvh flex-col">
|
||||
<app-header (menuToggle)="toggleSidebar()" [drawerOpen]="sidebarOpen()"></app-header>
|
||||
|
||||
<!-- Reserve exactly the space the header actually occupies (safe-area aware). -->
|
||||
<div [style.paddingTop]="'var(--header-offset)'">
|
||||
<!-- Global Themes bar: shown on Home and Theme pages, sticky under header -->
|
||||
@if (showThemesBar()) {
|
||||
<app-themes-nav></app-themes-nav>
|
||||
}
|
||||
<div class="flex flex-1 relative">
|
||||
<!-- Desktop sidebar -->
|
||||
<aside class="hidden md:block border-r border-slate-800 sticky top-16 self-start h-[calc(100vh-4rem)] transition-[width] duration-200"
|
||||
<aside class="hidden md:block border-r border-slate-800 sticky self-start transition-[width] duration-200"
|
||||
[style.top]="'var(--header-offset)'"
|
||||
[style.height]="'calc(var(--app-vh) - var(--header-offset))'"
|
||||
[class.w-64]="!sidebarCollapsed()" [class.w-20]="sidebarCollapsed()">
|
||||
<app-sidebar [collapsed]="sidebarCollapsed()"></app-sidebar>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile drawer sidebar -->
|
||||
<!-- Mobile drawer: sits *below* the fixed header so nothing is hidden
|
||||
behind it, and owns the remaining viewport height. -->
|
||||
@if (sidebarOpen()) {
|
||||
<div class="fixed inset-0 bg-black/60 z-40 md:hidden" (click)="closeSidebar()"></div>
|
||||
<div class="fixed inset-0 z-40 bg-black/60 md:hidden" (click)="closeSidebar()"></div>
|
||||
}
|
||||
<div class="fixed inset-y-0 left-0 w-72 z-40 bg-slate-900 transform transition-transform duration-200 md:hidden"
|
||||
[class.-translate-x-full]="!sidebarOpen()" [class.translate-x-0]="sidebarOpen()">
|
||||
<app-sidebar></app-sidebar>
|
||||
<div class="fixed left-0 z-40 w-[min(18rem,85vw)] bg-slate-900 shadow-2xl transition-transform duration-200 ease-out md:hidden"
|
||||
[style.top]="'var(--header-offset)'"
|
||||
[style.height]="'calc(var(--app-vh) - var(--header-offset))'"
|
||||
[style.paddingBottom]="'var(--safe-bottom)'"
|
||||
[class.-translate-x-full]="!sidebarOpen()" [class.translate-x-0]="sidebarOpen()"
|
||||
[attr.aria-hidden]="!sidebarOpen()"
|
||||
[attr.inert]="!sidebarOpen() ? '' : null">
|
||||
<app-sidebar [inDrawer]="true" (drawerClose)="closeSidebar()"></app-sidebar>
|
||||
</div>
|
||||
|
||||
<main class="flex-1 min-w-0 overflow-x-hidden">
|
||||
<main id="main-content" class="flex-1 min-w-0 overflow-x-hidden">
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
+66
-10
@@ -1,19 +1,23 @@
|
||||
|
||||
import { ChangeDetectionStrategy, Component, signal, inject } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, HostListener, signal, inject } from '@angular/core';
|
||||
import { Router, RouterOutlet, NavigationEnd } from '@angular/router';
|
||||
import { HeaderComponent } from './components/header/header.component';
|
||||
import { SidebarComponent } from './components/sidebar/sidebar.component';
|
||||
import { ThemesNavComponent } from './components/themes/themes-nav.component';
|
||||
import { TranslatePipe } from './pipes/translate.pipe';
|
||||
import { I18nService } from './services/i18n.service';
|
||||
import { AuthService } from './services/auth.service';
|
||||
import { ThemesService } from './services/themes.service';
|
||||
|
||||
/** Tailwind's `md` breakpoint — below it the sidebar is a drawer, above it a rail. */
|
||||
const MD_BREAKPOINT = 768;
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterOutlet, HeaderComponent, SidebarComponent, ThemesNavComponent]
|
||||
imports: [RouterOutlet, HeaderComponent, SidebarComponent, ThemesNavComponent, TranslatePipe]
|
||||
})
|
||||
export class AppComponent {
|
||||
private i18n = inject(I18nService);
|
||||
@@ -24,22 +28,71 @@ export class AppComponent {
|
||||
sidebarCollapsed = signal(false);
|
||||
private _showThemesBar = signal(false);
|
||||
|
||||
private lastFocused: HTMLElement | null = null;
|
||||
|
||||
private isMobileViewport(): boolean {
|
||||
try {
|
||||
return typeof window !== 'undefined' && window.innerWidth < MD_BREAKPOINT;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
toggleSidebar() {
|
||||
// On mobile (< md), open/close drawer. On desktop, collapse/expand sidebar width.
|
||||
try {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; // Tailwind md breakpoint
|
||||
if (isMobile) {
|
||||
this.sidebarOpen.update(v => !v);
|
||||
if (this.isMobileViewport()) {
|
||||
if (this.sidebarOpen()) {
|
||||
this.closeSidebar();
|
||||
} else {
|
||||
this.sidebarCollapsed.update(v => !v);
|
||||
try { this.lastFocused = document.activeElement as HTMLElement | null; } catch {}
|
||||
this.sidebarOpen.set(true);
|
||||
}
|
||||
} catch {
|
||||
this.sidebarOpen.update(v => !v);
|
||||
this.lockScroll(this.sidebarOpen());
|
||||
} else {
|
||||
this.sidebarCollapsed.update(v => !v);
|
||||
}
|
||||
}
|
||||
|
||||
closeSidebar() {
|
||||
if (!this.sidebarOpen()) return;
|
||||
this.sidebarOpen.set(false);
|
||||
this.lockScroll(false);
|
||||
try { this.lastFocused?.focus(); } catch {}
|
||||
this.lastFocused = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawer owns the viewport on phones: freeze the page behind it so a
|
||||
* scroll gesture never leaks to the content.
|
||||
*/
|
||||
private lockScroll(locked: boolean) {
|
||||
try { document.body.classList.toggle('scroll-locked', locked); } catch {}
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape() {
|
||||
if (this.sidebarOpen()) this.closeSidebar();
|
||||
}
|
||||
|
||||
// Leaving the mobile breakpoint must not leave the drawer "open" behind the
|
||||
// desktop rail, and vice versa.
|
||||
@HostListener('window:resize')
|
||||
onResize() {
|
||||
if (!this.isMobileViewport() && this.sidebarOpen()) {
|
||||
this.sidebarOpen.set(false);
|
||||
this.lockScroll(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Navigating on mobile closes the drawer, otherwise the drawer covers the page
|
||||
// the user just asked for.
|
||||
@HostListener('document:click', ['$event'])
|
||||
onDocumentClick(ev: MouseEvent) {
|
||||
if (!this.sidebarOpen()) return;
|
||||
const target = ev.target as HTMLElement | null;
|
||||
if (target && target.closest('.fixed.left-0')) return; // inside the drawer
|
||||
if (target && target.closest('[data-drawer-toggle]')) return; // burger button
|
||||
this.closeSidebar();
|
||||
}
|
||||
|
||||
constructor() {
|
||||
@@ -54,7 +107,10 @@ export class AppComponent {
|
||||
try {
|
||||
this.updateShowThemesBar();
|
||||
this.router.events.subscribe(e => {
|
||||
if (e instanceof NavigationEnd) this.updateShowThemesBar();
|
||||
if (e instanceof NavigationEnd) {
|
||||
this.updateShowThemesBar();
|
||||
if (this.sidebarOpen()) this.closeSidebar();
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,44 @@
|
||||
<div class="container mx-auto p-4 sm:p-6 max-w-6xl">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-6 gap-4">
|
||||
<h2 class="text-3xl font-bold 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">
|
||||
<div class="mb-6 flex flex-col flex-wrap gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<h2 class="flex items-center border-l-4 border-red-500 pl-4 text-2xl font-bold text-slate-100 sm:text-3xl">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mr-2 h-7 w-7 shrink-0 sm:h-8 sm:w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<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="flex items-center gap-2">
|
||||
<button (click)="resetFilters()" [disabled]="!hasActiveFilters()"
|
||||
class="px-3 py-2 text-xs rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700 disabled:opacity-40 transition-colors">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button type="button" (click)="resetFilters()" [disabled]="!hasActiveFilters()"
|
||||
class="chip px-3 py-2 text-xs rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700 disabled:opacity-40 transition-colors">
|
||||
Réinitialiser les filtres
|
||||
</button>
|
||||
<button (click)="clearActiveTab()" [disabled]="!!clearingTab()"
|
||||
class="px-3 py-2 text-xs rounded-lg bg-red-600/90 hover:bg-red-500 text-white disabled:opacity-60 transition-colors">
|
||||
<button type="button" (click)="clearActiveTab()" [disabled]="!!clearingTab()"
|
||||
class="chip px-3 py-2 text-xs rounded-lg bg-red-600/90 hover:bg-red-500 text-white disabled:opacity-60 transition-colors">
|
||||
<span *ngIf="!clearingTab()">Tout effacer (onglet)</span>
|
||||
<span *ngIf="clearingTab()">Suppression…</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-2 mb-6 border-b border-slate-700" role="tablist">
|
||||
<button role="tab" (click)="setTab('search')"
|
||||
<!-- Tabs: swipeable rail on narrow screens so no tab is ever unreachable -->
|
||||
<div class="scroll-rail no-scrollbar mb-6 flex gap-2 border-b border-slate-700" role="tablist">
|
||||
<button type="button" role="tab" (click)="setTab('search')"
|
||||
class="chip shrink-0 whitespace-nowrap"
|
||||
[class]="activeTab() === 'search'
|
||||
? 'px-4 py-2 text-sm font-semibold text-white border-b-2 border-blue-500 -mb-px'
|
||||
: 'px-4 py-2 text-sm text-slate-400 hover:text-slate-200 border-b-2 border-transparent -mb-px'">
|
||||
Recherches
|
||||
<span class="ml-1 text-xs bg-slate-700 px-2 py-0.5 rounded-full">{{ filteredSearch().length }}</span>
|
||||
</button>
|
||||
<button role="tab" (click)="setTab('watch')"
|
||||
<button type="button" role="tab" (click)="setTab('watch')"
|
||||
class="chip shrink-0 whitespace-nowrap"
|
||||
[class]="activeTab() === 'watch'
|
||||
? 'px-4 py-2 text-sm font-semibold text-white border-b-2 border-red-500 -mb-px'
|
||||
: 'px-4 py-2 text-sm text-slate-400 hover:text-slate-200 border-b-2 border-transparent -mb-px'">
|
||||
Visionnage
|
||||
<span class="ml-1 text-xs bg-slate-700 px-2 py-0.5 rounded-full">{{ filteredWatch().length }}</span>
|
||||
</button>
|
||||
<button role="tab" (click)="setTab('transcripts')"
|
||||
<button type="button" role="tab" (click)="setTab('transcripts')"
|
||||
class="chip shrink-0 whitespace-nowrap"
|
||||
[class]="activeTab() === 'transcripts'
|
||||
? 'px-4 py-2 text-sm font-semibold text-white border-b-2 border-emerald-500 -mb-px'
|
||||
: 'px-4 py-2 text-sm text-slate-400 hover:text-slate-200 border-b-2 border-transparent -mb-px'">
|
||||
@@ -144,7 +147,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ===== Contenu : RECHERCHES ===== -->
|
||||
<section *ngIf="activeTab() === 'search'" class="bg-slate-800/50 rounded-lg p-6 shadow-lg">
|
||||
<section *ngIf="activeTab() === 'search'" class="bg-slate-800/50 rounded-lg p-4 shadow-lg sm:p-6">
|
||||
<div *ngIf="searchLoading() && filteredSearch().length === 0" class="text-center py-6">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-4 border-blue-500 border-t-transparent"></div>
|
||||
<p class="mt-2 text-slate-400">Chargement…</p>
|
||||
@@ -155,7 +158,7 @@
|
||||
<ul *ngIf="filteredSearch().length > 0" class="space-y-3">
|
||||
<li *ngFor="let s of filteredSearch()"
|
||||
class="group relative bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
|
||||
<a [routerLink]="['/search']" [queryParams]="getSearchQueryParams(s)" class="block p-4 pr-24 hover:no-underline">
|
||||
<a [routerLink]="['/search']" [queryParams]="getSearchQueryParams(s)" class="block p-4 pr-4 pb-14 hover:no-underline sm:pr-24 sm:pb-4">
|
||||
<div class="text-slate-100 font-medium group-hover:text-blue-400 transition-colors" [innerHTML]="s.query | histHighlight: searchQuery()"></div>
|
||||
<div class="flex items-center mt-1 text-xs text-slate-400 gap-2">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded border" [ngStyle]="getProviderColors(getSearchProvider(s).id)">
|
||||
@@ -166,21 +169,21 @@
|
||||
</div>
|
||||
</a>
|
||||
<button (click)="onDeleteSearch(s)" [disabled]="deletingSearchId() === s.id"
|
||||
class="absolute bottom-2 right-2 px-2 py-1 rounded-md bg-red-600/90 hover:bg-red-500 text-white text-xs disabled:opacity-60">
|
||||
class="chip absolute bottom-2 right-2 rounded-md bg-red-600/90 hover:bg-red-500 px-3 py-2 text-white text-xs disabled:opacity-60">
|
||||
Supprimer
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-4 text-center" *ngIf="searchHasMore() && filteredSearch().length > 0">
|
||||
<button (click)="loadMore()" [disabled]="searchLoading()"
|
||||
class="px-4 py-2 text-sm rounded-lg border border-slate-600 text-slate-200 hover:bg-slate-700 disabled:opacity-50">
|
||||
class="chip px-4 py-3 text-sm rounded-lg border border-slate-600 text-slate-200 hover:bg-slate-700 disabled:opacity-50">
|
||||
{{ searchLoading() ? 'Chargement…' : 'Charger plus' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== Contenu : VISIONNAGE ===== -->
|
||||
<section *ngIf="activeTab() === 'watch'" class="bg-slate-800/50 rounded-lg p-6 shadow-lg">
|
||||
<section *ngIf="activeTab() === 'watch'" class="bg-slate-800/50 rounded-lg p-4 shadow-lg sm:p-6">
|
||||
<div *ngIf="watchLoading() && filteredWatch().length === 0" class="text-center py-6">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-4 border-red-500 border-t-transparent"></div>
|
||||
<p class="mt-2 text-slate-400">Chargement…</p>
|
||||
@@ -191,7 +194,7 @@
|
||||
<ul *ngIf="filteredWatch().length > 0" class="space-y-3">
|
||||
<li *ngFor="let w of filteredWatch()"
|
||||
class="group relative 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 pr-24 hover:no-underline">
|
||||
<a [routerLink]="['/watch', w.video_id]" [queryParams]="{ p: w.provider }" class="block p-4 pr-4 pb-14 hover:no-underline sm:pr-24 sm:pb-4">
|
||||
<div class="flex">
|
||||
<div class="relative flex-shrink-0 w-24 h-16 bg-slate-700 rounded overflow-hidden flex items-center justify-center">
|
||||
@if (thumbOf(w)) { <img [src]="thumbOf(w)" alt="" loading="lazy" class="w-full h-full object-cover" /> }
|
||||
@@ -215,21 +218,21 @@
|
||||
</div>
|
||||
</a>
|
||||
<button (click)="onDeleteWatch(w)" [disabled]="deletingWatchId() === w.id"
|
||||
class="absolute bottom-2 right-2 px-2 py-1 rounded-md bg-red-600/90 hover:bg-red-500 text-white text-xs disabled:opacity-60">
|
||||
class="chip absolute bottom-2 right-2 rounded-md bg-red-600/90 hover:bg-red-500 px-3 py-2 text-white text-xs disabled:opacity-60">
|
||||
Supprimer
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-4 text-center" *ngIf="watchHasMore() && filteredWatch().length > 0">
|
||||
<button (click)="loadMore()" [disabled]="watchLoading()"
|
||||
class="px-4 py-2 text-sm rounded-lg border border-slate-600 text-slate-200 hover:bg-slate-700 disabled:opacity-50">
|
||||
class="chip px-4 py-3 text-sm rounded-lg border border-slate-600 text-slate-200 hover:bg-slate-700 disabled:opacity-50">
|
||||
{{ watchLoading() ? 'Chargement…' : 'Charger plus' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== Contenu : TRANSCRIPTS ===== -->
|
||||
<section *ngIf="activeTab() === 'transcripts'" class="bg-slate-800/50 rounded-lg p-6 shadow-lg">
|
||||
<section *ngIf="activeTab() === 'transcripts'" class="bg-slate-800/50 rounded-lg p-4 shadow-lg sm:p-6">
|
||||
<div *ngIf="transcriptLoading() && filteredTranscripts().length === 0" class="text-center py-6">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-4 border-emerald-500 border-t-transparent"></div>
|
||||
<p class="mt-2 text-slate-400">Chargement…</p>
|
||||
@@ -283,7 +286,7 @@
|
||||
</ul>
|
||||
<div class="mt-4 text-center" *ngIf="transcriptHasMore() && filteredTranscripts().length > 0">
|
||||
<button (click)="loadMore()" [disabled]="transcriptLoading()"
|
||||
class="px-4 py-2 text-sm rounded-lg border border-slate-600 text-slate-200 hover:bg-slate-700 disabled:opacity-50">
|
||||
class="chip px-4 py-3 text-sm rounded-lg border border-slate-600 text-slate-200 hover:bg-slate-700 disabled:opacity-50">
|
||||
{{ transcriptLoading() ? 'Chargement…' : 'Charger plus' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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>
|
||||
<h2 class="mb-6 border-l-4 border-red-500 pl-4 text-2xl font-bold text-slate-100 sm:text-3xl">{{ '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>
|
||||
@@ -9,14 +9,14 @@
|
||||
<!-- 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 class="flex flex-col gap-3 border-b border-slate-700 px-4 py-4 sm:px-5 lg:flex-row lg:items-center lg: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>
|
||||
<button type="submit" [disabled]="saving()" class="chip w-full rounded bg-red-600 px-4 py-3 hover:bg-red-500 disabled:opacity-50 sm:w-auto sm:py-2">{{ 'btn.save' | t : 'Enregistrer' }}</button>
|
||||
</div>
|
||||
<div class="p-5 grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div class="grid grid-cols-1 gap-5 p-4 sm:p-5 md:grid-cols-2">
|
||||
<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">
|
||||
@@ -53,16 +53,16 @@
|
||||
|
||||
<!-- Langues à télécharger (sous-titres/transcripts) -->
|
||||
<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">
|
||||
<div class="border-b border-slate-700 px-4 py-4 sm:px-5">
|
||||
<h3 class="text-lg font-semibold text-slate-100">{{ 'preferences.download_languages' | t : 'Langues à télécharger' }}</h3>
|
||||
<p class="text-sm text-slate-400">{{ 'preferences.download_languages_hint' | t : 'Seules les langues activées sont affichées et téléchargées (sous-titres/transcriptions). Français et anglais par défaut.' }}</p>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
<div class="p-4 sm:p-5">
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3 md:grid-cols-3 lg:grid-cols-4">
|
||||
<label *ngFor="let opt of downloadLanguageOptions"
|
||||
class="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 cursor-pointer hover:border-slate-500">
|
||||
class="flex cursor-pointer items-center gap-2 rounded-lg border border-slate-700 bg-slate-900 px-3 py-3 hover:border-slate-500 sm:py-2">
|
||||
<input type="checkbox"
|
||||
class="h-4 w-4 accent-red-600"
|
||||
class="h-5 w-5 shrink-0 accent-red-600"
|
||||
[checked]="isDownloadLanguageEnabled(opt.code)"
|
||||
(change)="toggleDownloadLanguage(opt.code)"
|
||||
[name]="'dl-' + opt.code" />
|
||||
@@ -78,35 +78,35 @@
|
||||
<!-- 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">
|
||||
<div class="border-b border-slate-700 px-4 py-4 sm:px-5">
|
||||
<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">
|
||||
<div class="space-y-4 p-4 sm:p-5">
|
||||
<form (submit)="addPeerTubeInstance($event)" class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input type="text" name="peertube"
|
||||
class="flex-1 rounded bg-slate-900 text-slate-100 border border-slate-700 px-3 py-2"
|
||||
class="min-w-0 flex-1 rounded bg-slate-900 text-slate-100 border border-slate-700 px-3 py-3 sm: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>
|
||||
<button type="submit" class="chip rounded bg-slate-700 px-4 py-3 text-white hover:bg-slate-600 sm:py-2">{{ '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"
|
||||
<li *ngFor="let host of peerTubeInstances()" class="flex flex-col gap-2 bg-slate-900 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-2">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="inline-block h-2 w-2 shrink-0 rounded-full"
|
||||
[ngClass]="{ 'bg-emerald-400': activePeerTubeInstance() === host, 'bg-slate-600': activePeerTubeInstance() !== host }"></span>
|
||||
<span class="text-slate-200">{{ host }}</span>
|
||||
<span class="min-w-0 truncate text-slate-200" [title]="host">{{ host }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button type="button" (click)="setActivePeerTube(host)"
|
||||
class="px-2 py-1 rounded border"
|
||||
class="chip px-3 py-2 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>
|
||||
<button type="button" (click)="removePeerTubeInstance(host)" class="chip px-3 py-2 rounded bg-red-600 hover:bg-red-500 text-white">{{ 'btn.remove' | t : 'Supprimer' }}</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<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>
|
||||
<h2 class="mb-6 border-l-4 border-red-500 pl-4 text-2xl font-bold text-slate-100 sm:text-3xl">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 *ngIf="error()" class="mb-4 rounded border border-amber-600 bg-amber-900/40 p-3 text-amber-200">{{ 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 class="mb-4 flex flex-col gap-3 sm:flex-row">
|
||||
<button type="button" class="chip w-full rounded bg-slate-700 px-4 py-3 text-white hover:bg-slate-600 sm:w-auto sm:py-2" (click)="reload()" [disabled]="loading()">Reload</button>
|
||||
<button type="button" class="chip w-full rounded border border-red-500/40 bg-red-600/20 px-4 py-3 text-red-200 hover:bg-red-600/40 sm:w-auto sm:py-2" (click)="logoutAll()" [disabled]="loading()">Logout all devices</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="loading()" class="text-slate-400">Loading...</div>
|
||||
@@ -13,9 +13,10 @@
|
||||
<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>
|
||||
<li *ngFor="let s of items()" class="flex flex-col gap-3 rounded bg-slate-800 p-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<!-- UUIDs and User-Agent strings cannot wrap on their own: force it. -->
|
||||
<div class="min-w-0 flex-1 break-anywhere text-sm text-slate-200">
|
||||
<div><span class="text-slate-400">Session ID:</span> <code class="break-all">{{ 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>
|
||||
@@ -25,8 +26,8 @@
|
||||
<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 class="shrink-0 sm:pl-4">
|
||||
<button type="button" class="chip w-full rounded bg-slate-700 px-4 py-3 text-white hover:bg-slate-600 sm:py-2" (click)="revoke(s.id)">Revoke</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="min-h-[calc(100vh-64px)] flex items-center justify-center py-10">
|
||||
<div class="w-full max-w-md bg-slate-800 rounded-xl shadow-xl p-8 text-center">
|
||||
<div class="flex min-h-[calc(var(--app-vh)-var(--header-offset))] items-center justify-center px-4 py-8 sm:py-10">
|
||||
<div class="w-full max-w-md bg-slate-800 rounded-xl shadow-xl p-5 text-center sm:p-8">
|
||||
<div *ngIf="!error()" class="text-slate-300">Connexion avec Google…</div>
|
||||
<div *ngIf="error() as e" class="space-y-4">
|
||||
<p class="rounded border border-red-600 bg-red-900/50 px-3 py-2 text-sm text-red-200">{{ e }}</p>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<div class="min-h-[calc(100vh-64px)] flex items-center justify-center py-10">
|
||||
<div class="w-full max-w-md bg-slate-800 rounded-xl shadow-xl p-8">
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-red-500" viewBox="0 0 24 24" fill="currentColor"><path d="M10,15.6l5.3-3.3L10,9V15.6z M21.8,8.1c-0.2-0.8-0.9-1.4-1.7-1.7C18.2,6,12,6,12,6s-6.2,0-8.1,0.5 c-0.8,0.2-1.4,0.9-1.7,1.7C2,9.9,2,12,2,12s0,2.1,0.5,3.9c0.2,0.8,0.9,1.4,1.7,1.7C6.2,18,12,18,12,18s6.2,0,8.1-0.5 c0.8-0.2,1.4-0.9,1.7-1.7c0.5-1.8,0.5-3.9,0.5-3.9S22.2,9.9,21.8,8.1z"/></svg>
|
||||
<div class="flex min-h-[calc(var(--app-vh)-var(--header-offset))] items-center justify-center px-4 py-8 sm:py-10">
|
||||
<div class="w-full max-w-md bg-slate-800 rounded-xl shadow-xl p-5 sm:p-8">
|
||||
<div class="mb-6 flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 shrink-0 text-red-500 sm:h-8 sm:w-8" viewBox="0 0 24 24" fill="currentColor"><path d="M10,15.6l5.3-3.3L10,9V15.6z M21.8,8.1c-0.2-0.8-0.9-1.4-1.7-1.7C18.2,6,12,6,12,6s-6.2,0-8.1,0.5 c-0.8,0.2-1.4,0.9-1.7,1.7C2,9.9,2,12,2,12s0,2.1,0.5,3.9c0.2,0.8,0.9,1.4,1.7,1.7C6.2,18,12,18,12,18s6.2,0,8.1-0.5 c0.8-0.2,1.4-0.9,1.7-1.7c0.5-1.8,0.5-3.9,0.5-3.9S22.2,9.9,21.8,8.1z"/></svg>
|
||||
<h1 class="text-2xl font-bold">Sign in to NewTube</h1>
|
||||
</div>
|
||||
|
||||
<form (submit)="$event.preventDefault(); submit()" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-slate-300 mb-1">Username (or Email)</label>
|
||||
<input [ngModel]="username()" (ngModelChange)="username.set($event)" name="username" autocomplete="username" class="w-full bg-slate-900 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" placeholder="yourname or [email protected]">
|
||||
<input [ngModel]="username()" (ngModelChange)="username.set($event)" name="username" autocomplete="username" class="w-full min-w-0 bg-slate-900 text-white rounded px-3 py-3 focus:outline-none focus:ring-2 focus:ring-red-500 sm:py-2" placeholder="yourname or [email protected]">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-slate-300 mb-1">Password</label>
|
||||
<input [ngModel]="password()" (ngModelChange)="password.set($event)" type="password" name="password" autocomplete="current-password" class="w-full bg-slate-900 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" placeholder="••••••••">
|
||||
<input [ngModel]="password()" (ngModelChange)="password.set($event)" type="password" name="password" autocomplete="current-password" class="w-full min-w-0 bg-slate-900 text-white rounded px-3 py-3 focus:outline-none focus:ring-2 focus:ring-red-500 sm:py-2" placeholder="••••••••">
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input type="checkbox" [ngModel]="remember()" (ngModelChange)="remember.set($event)" name="remember" class="accent-red-600">
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<div *ngIf="error()" class="bg-red-900/50 border border-red-600 text-red-200 rounded px-3 py-2 text-sm">{{ error() }}</div>
|
||||
|
||||
<button [disabled]="busy()" class="w-full py-2 rounded bg-red-600 hover:bg-red-500 disabled:opacity-60">Sign in</button>
|
||||
<button [disabled]="busy()" type="submit" class="chip w-full rounded bg-red-600 py-3 hover:bg-red-500 disabled:opacity-60 sm:py-2">Sign in</button>
|
||||
</form>
|
||||
|
||||
<div *ngIf="googleAvailable()" class="mt-4">
|
||||
@@ -29,8 +29,8 @@
|
||||
<span class="h-px flex-1 bg-slate-700"></span><span>ou</span><span class="h-px flex-1 bg-slate-700"></span>
|
||||
</div>
|
||||
<button type="button" [disabled]="googleBusy()" (click)="loginWithGoogle()"
|
||||
class="flex w-full items-center justify-center gap-2 rounded border border-slate-600 bg-white px-3 py-2 font-medium text-slate-800 transition hover:bg-slate-100 disabled:opacity-60">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true"><path fill="#4285F4" d="M23.5 12.3c0-.9-.1-1.5-.3-2.3H12v4.5h6.5c-.1 1.1-.8 2.7-2.4 3.8l-.1.4 3.5 2.7.2.1c2.2-2 3.6-5 3.6-9.2z"/><path fill="#34A853" d="M12 24c3.2 0 5.9-1.1 7.9-2.9l-3.8-2.9c-1 .7-2.4 1.2-4.1 1.2-3.1 0-5.8-2.1-6.8-5l-.4.1-3.6 2.8v.3C3.3 21.3 7.3 24 12 24z"/><path fill="#FBBC05" d="M5.2 14.4c-.2-.7-.4-1.5-.4-2.4s.1-1.7.4-2.4l-.1-.4-3.6-2.8-.1.1C.5 8.3 0 10 0 12s.5 3.7 1.4 5.3l3.8-2.9z"/><path fill="#EA4335" d="M12 4.6c1.8 0 3 .8 3.7 1.4l3.3-3.2C17.9 1 15.2 0 12 0 7.3 0 3.3 2.7 1.4 6.7l3.8 2.9c1-2.9 3.7-5 6.8-5z"/></svg>
|
||||
class="chip flex w-full items-center justify-center gap-2 rounded border border-slate-600 bg-white px-3 py-3 font-medium text-slate-800 transition hover:bg-slate-100 disabled:opacity-60">
|
||||
<svg class="h-5 w-5 shrink-0" viewBox="0 0 24 24" aria-hidden="true"><path fill="#4285F4" d="M23.5 12.3c0-.9-.1-1.5-.3-2.3H12v4.5h6.5c-.1 1.1-.8 2.7-2.4 3.8l-.1.4 3.5 2.7.2.1c2.2-2 3.6-5 3.6-9.2z"/><path fill="#34A853" d="M12 24c3.2 0 5.9-1.1 7.9-2.9l-3.8-2.9c-1 .7-2.4 1.2-4.1 1.2-3.1 0-5.8-2.1-6.8-5l-.4.1-3.6 2.8v.3C3.3 21.3 7.3 24 12 24z"/><path fill="#FBBC05" d="M5.2 14.4c-.2-.7-.4-1.5-.4-2.4s.1-1.7.4-2.4l-.1-.4-3.6-2.8-.1.1C.5 8.3 0 10 0 12s.5 3.7 1.4 5.3l3.8-2.9z"/><path fill="#EA4335" d="M12 4.6c1.8 0 3 .8 3.7 1.4l3.3-3.2C17.9 1 15.2 0 12 0 7.3 0 3.3 2.7 1.4 6.7l3.8 2.9c1-2.9 3.7-5 6.8-5z"/></svg>
|
||||
{{ googleBusy() ? 'Redirection…' : 'Se connecter avec Google' }}
|
||||
</button>
|
||||
<p class="mt-2 text-xs text-slate-500">Crée ou lie votre compte NewTube. Les abonnements et favoris YouTube deviennent importables depuis Bibliothèque › Importer.</p>
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
<div class="min-h-[calc(100vh-64px)] flex items-center justify-center py-10">
|
||||
<div class="w-full max-w-md bg-slate-800 rounded-xl shadow-xl p-8">
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-red-500" viewBox="0 0 24 24" fill="currentColor"><path d="M10,15.6l5.3-3.3L10,9V15.6z M21.8,8.1c-0.2-0.8-0.9-1.4-1.7-1.7C18.2,6,12,6,12,6s-6.2,0-8.1,0.5 c-0.8,0.2-1.4,0.9-1.7,1.7C2,9.9,2,12,2,12s0,2.1,0.5,3.9c0.2,0.8,0.9,1.4,1.7,1.7C6.2,18,12,18,12,18s6.2,0,8.1-0.5 c0.8-0.2,1.4-0.9,1.7-1.7c0.5-1.8,0.5-3.9,0.5-3.9S22.2,9.9,21.8,8.1z"/></svg>
|
||||
<div class="flex min-h-[calc(var(--app-vh)-var(--header-offset))] items-center justify-center px-4 py-8 sm:py-10">
|
||||
<div class="w-full max-w-md bg-slate-800 rounded-xl shadow-xl p-5 sm:p-8">
|
||||
<div class="mb-6 flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 shrink-0 text-red-500 sm:h-8 sm:w-8" viewBox="0 0 24 24" fill="currentColor"><path d="M10,15.6l5.3-3.3L10,9V15.6z M21.8,8.1c-0.2-0.8-0.9-1.4-1.7-1.7C18.2,6,12,6,12,6s-6.2,0-8.1,0.5 c-0.8,0.2-1.4,0.9-1.7,1.7C2,9.9,2,12,2,12s0,2.1,0.5,3.9c0.2,0.8,0.9,1.4,1.7,1.7C6.2,18,12,18,12,18s6.2,0,8.1-0.5 c0.8-0.2,1.4-0.9,1.7-1.7c0.5-1.8,0.5-3.9,0.5-3.9S22.2,9.9,21.8,8.1z"/></svg>
|
||||
<h1 class="text-2xl font-bold">Create your account</h1>
|
||||
</div>
|
||||
|
||||
<form (submit)="$event.preventDefault(); submit()" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-slate-300 mb-1">Username (or Email)</label>
|
||||
<input [ngModel]="username()" (ngModelChange)="username.set($event)" name="username" autocomplete="username" class="w-full bg-slate-900 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" placeholder="yourname (or leave empty to use email)">
|
||||
<input [ngModel]="username()" (ngModelChange)="username.set($event)" name="username" autocomplete="username" class="w-full min-w-0 bg-slate-900 text-white rounded px-3 py-3 focus:outline-none focus:ring-2 focus:ring-red-500 sm:py-2" placeholder="yourname (or leave empty to use email)">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-slate-300 mb-1">Email (optional)</label>
|
||||
<input [ngModel]="email()" (ngModelChange)="email.set($event)" type="email" name="email" autocomplete="email" class="w-full bg-slate-900 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" placeholder="[email protected]">
|
||||
<input [ngModel]="email()" (ngModelChange)="email.set($event)" type="email" name="email" autocomplete="email" class="w-full min-w-0 bg-slate-900 text-white rounded px-3 py-3 focus:outline-none focus:ring-2 focus:ring-red-500 sm:py-2" placeholder="[email protected]">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-slate-300 mb-1">Password</label>
|
||||
<input [ngModel]="password()" (ngModelChange)="password.set($event)" type="password" name="new-password" autocomplete="new-password" class="w-full bg-slate-900 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" placeholder="••••••••">
|
||||
<input [ngModel]="password()" (ngModelChange)="password.set($event)" type="password" name="new-password" autocomplete="new-password" class="w-full min-w-0 bg-slate-900 text-white rounded px-3 py-3 focus:outline-none focus:ring-2 focus:ring-red-500 sm:py-2" placeholder="••••••••">
|
||||
</div>
|
||||
|
||||
<div *ngIf="error()" class="bg-red-900/50 border border-red-600 text-red-200 rounded px-3 py-2 text-sm">{{ error() }}</div>
|
||||
|
||||
<button [disabled]="busy()" class="w-full py-2 rounded bg-red-600 hover:bg-red-500 disabled:opacity-60">Create account</button>
|
||||
<button [disabled]="busy()" type="submit" class="chip w-full rounded bg-red-600 py-3 hover:bg-red-500 disabled:opacity-60 sm:py-2">Create account</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-sm text-slate-300">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div *ngIf="headerError() && !headerLoading()" class="rounded-xl border border-red-500/40 bg-red-500/10 p-8 text-center">
|
||||
<h2 class="mb-2 text-xl font-semibold text-red-100">Impossible de charger la chaîne</h2>
|
||||
<p class="text-sm text-red-200/80">{{ headerError() }}</p>
|
||||
<button type="button" (click)="refreshAll(true)" class="mt-4 rounded-md border border-red-500/60 px-4 py-1.5 text-sm text-red-100 hover:bg-red-500/20">
|
||||
<button type="button" (click)="refreshAll(true)" class="chip mt-4 rounded-md border border-red-500/60 px-4 py-3 text-sm text-red-100 hover:bg-red-500/20 sm:py-2">
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
@@ -22,7 +22,7 @@
|
||||
id="channel-sort"
|
||||
[value]="sort()"
|
||||
(change)="onSortChange($any($event.target).value)"
|
||||
class="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-sm text-zinc-100"
|
||||
class="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-2.5 text-sm text-zinc-100 sm:py-1.5"
|
||||
>
|
||||
<option value="recent">Plus récentes</option>
|
||||
<option value="popular">Plus populaires</option>
|
||||
@@ -34,10 +34,10 @@
|
||||
placeholder="Rechercher dans cette chaîne…"
|
||||
[value]="searchQuery()"
|
||||
(input)="onSearchInput($any($event.target).value)"
|
||||
class="w-full rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 pr-9 text-sm text-zinc-100 placeholder:text-zinc-500 focus:border-red-500 focus:outline-none"
|
||||
class="w-full min-w-0 rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-3 pr-10 text-sm text-zinc-100 placeholder:text-zinc-500 focus:border-red-500 focus:outline-none sm:py-2 sm:pr-9"
|
||||
/>
|
||||
<button *ngIf="searchQuery()" type="button" (click)="clearSearch()" aria-label="Effacer"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 rounded px-1 text-zinc-400 hover:text-white">✕</button>
|
||||
class="absolute right-1 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded text-zinc-400 hover:text-white">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { VideoCardSkeletonComponent } from '../../../app/shared/components/video
|
||||
</div>
|
||||
<div *ngIf="!loading && error" class="flex flex-col items-center gap-3 rounded-xl border border-red-500/40 bg-red-500/10 p-8 text-center">
|
||||
<h3 class="text-lg font-semibold text-red-100">Impossible de charger les playlists</h3>
|
||||
<button type="button" (click)="retry.emit()" class="rounded-md border border-red-500/60 px-4 py-1.5 text-sm text-red-100 hover:bg-red-500/20">Réessayer</button>
|
||||
<button type="button" (click)="retry.emit()" class="chip rounded-md border border-red-500/60 px-4 py-3 text-sm text-red-100 hover:bg-red-500/20 sm:py-2">Réessayer</button>
|
||||
</div>
|
||||
<div *ngIf="!loading && !error && playlists.length === 0" class="flex flex-col items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900/50 p-10 text-center">
|
||||
<h3 class="text-lg font-semibold text-white">Aucune playlist trouvée</h3>
|
||||
@@ -24,7 +24,7 @@ import { VideoCardSkeletonComponent } from '../../../app/shared/components/video
|
||||
<div class="relative aspect-video w-full overflow-hidden bg-zinc-800">
|
||||
<img *ngIf="pl.thumbnailUrl; else noThumb" [src]="pl.thumbnailUrl!" [alt]="pl.title" loading="lazy" class="h-full w-full object-cover" />
|
||||
<ng-template #noThumb><div class="flex h-full w-full items-center justify-center text-4xl text-zinc-600">▤</div></ng-template>
|
||||
<div class="absolute inset-y-0 right-0 flex w-24 flex-col items-center justify-center gap-1 bg-black/70 text-white">
|
||||
<div class="absolute inset-y-0 right-0 flex w-16 flex-col items-center justify-center gap-1 bg-black/70 text-white sm:w-24">
|
||||
<span class="text-xl font-bold">{{ pl.videoCount ?? '–' }}</span>
|
||||
<span class="text-xs">vidéos</span>
|
||||
</div>
|
||||
|
||||
@@ -20,15 +20,19 @@ const ALL_TABS: ChannelTabDef[] = [
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<nav class="sticky top-0 z-10 -mx-4 border-b border-zinc-800 bg-zinc-950/95 px-4 backdrop-blur" aria-label="Onglets chaîne">
|
||||
<div class="flex gap-1 overflow-x-auto py-2" role="tablist">
|
||||
<!-- Sticky offset must clear the fixed app header, otherwise the tab rail
|
||||
slides underneath it as soon as the page scrolls. -->
|
||||
<nav class="sticky z-20 -mx-4 border-b border-zinc-800 bg-zinc-950/95 px-4 backdrop-blur"
|
||||
[style.top]="'var(--header-offset)'"
|
||||
aria-label="Onglets chaîne">
|
||||
<div class="scroll-rail no-scrollbar flex gap-1 py-2" role="tablist">
|
||||
<button
|
||||
*ngFor="let tab of visibleTabs"
|
||||
type="button"
|
||||
role="tab"
|
||||
[attr.aria-selected]="tab.id === active"
|
||||
(click)="select.emit(tab.id)"
|
||||
class="whitespace-nowrap rounded-lg px-4 py-2 text-sm font-medium transition"
|
||||
class="chip shrink-0 whitespace-nowrap rounded-lg px-4 py-2 text-sm font-medium transition"
|
||||
[class.bg-red-500/20]="tab.id === active"
|
||||
[class.text-red-100]="tab.id === active"
|
||||
[class.text-zinc-400]="tab.id !== active"
|
||||
|
||||
@@ -1,129 +1,263 @@
|
||||
|
||||
<header class="bg-slate-800/50 backdrop-blur-sm fixed top-0 left-0 right-0 z-50 shadow-lg w-full border-b border-slate-700/60">
|
||||
<div class="w-full px-0 py-3 grid items-center gap-4 relative grid-cols-[auto_1fr_auto]">
|
||||
<header class="app-header bg-slate-800/50 backdrop-blur-sm fixed inset-x-0 top-0 z-50 shadow-lg border-b border-slate-700/60"
|
||||
[style.paddingTop]="'var(--safe-top)'"
|
||||
[style.paddingLeft]="'var(--safe-left)'"
|
||||
[style.paddingRight]="'var(--safe-right)'">
|
||||
|
||||
<!-- Top row: burger + logo (always) / search (desktop) / actions -->
|
||||
<div class="flex items-center gap-1 h-14 md:h-16 px-2 md:px-4">
|
||||
|
||||
<!-- Left: burger + logo -->
|
||||
<div class="flex items-center gap-3 shrink-0 pl-3 md:pl-4">
|
||||
<button (click)="menuToggle.emit()" class="p-2 rounded-full hover:bg-slate-700 focus:outline-none" aria-label="Toggle menu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-200" viewBox="0 0 24 24" fill="currentColor">
|
||||
<div class="flex items-center gap-1 md:gap-2 shrink-0">
|
||||
<button type="button" (click)="menuToggle.emit()"
|
||||
data-drawer-toggle
|
||||
class="app-icon-btn focus-ring" aria-label="Toggle menu" [attr.aria-expanded]="drawerOpen">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-200" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M3 6h18v2H3V6zm0 5h18v2H3v-2zm0 5h18v2H3v-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<a routerLink="/" class="flex items-center space-x-2 shrink-0">
|
||||
<img src="images/NewTube.png" alt="" class="h-8 w-auto" />
|
||||
<h1 class="text-2xl font-bold tracking-tight text-white">NewTube</h1>
|
||||
<a routerLink="/" class="flex items-center gap-2 shrink-0 focus-ring rounded-lg px-1" aria-label="NewTube">
|
||||
<img src="images/NewTube.png" alt="" class="h-7 md:h-8 w-auto" />
|
||||
<!-- Wordmark only when there is room for it (>= 400px). -->
|
||||
<span class="hidden min-[400px]:inline text-xl md:text-2xl font-bold tracking-tight text-white">NewTube</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Center: search box with providers -->
|
||||
<div class="justify-self-center mx-auto w-full max-w-2xl md:max-w-3xl lg:max-w-4xl px-4 relative">
|
||||
<app-search-box [placeholder]="('search.placeholder' | t)" (submitted)="onSearchBoxSubmit($event)"></app-search-box>
|
||||
<!-- Center: search (desktop + tablet inline). On phones it is moved into
|
||||
the slide-down search sheet below via the same DOM node. -->
|
||||
<div class="hidden md:flex flex-1 min-w-0 justify-center px-2 lg:px-4">
|
||||
<div class="relative w-full max-w-2xl lg:max-w-4xl">
|
||||
<app-search-box [placeholder]="('search.placeholder' | t)"
|
||||
(submitted)="onSearchBoxSubmit($event)"></app-search-box>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: user -->
|
||||
<div class="justify-self-end flex items-center gap-3 pr-3 md:pr-4">
|
||||
<!-- Toggle global de la bande des thèmes -->
|
||||
<button (click)="toggleThemesBar()"
|
||||
<!-- Right: actions -->
|
||||
<div class="ml-auto flex items-center gap-0.5 md:gap-2 shrink-0">
|
||||
|
||||
<!-- Phone: open the search sheet -->
|
||||
<button type="button" (click)="toggleMobileSearch()" class="app-icon-btn md:hidden focus-ring"
|
||||
[attr.aria-expanded]="mobileSearchOpen()" aria-controls="mobile-search-sheet"
|
||||
[attr.aria-label]="mobileSearchOpen() ? ('menu.close' | t) : ('menu.search' | t)">
|
||||
@if (mobileSearchOpen()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6.4 5 5 6.4 10.6 12 5 17.6 6.4 19 12 13.4 17.6 19 19 17.6 13.4 12 19 6.4 17.6 5 12 10.6 6.4 5Z"/>
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5Zm-6 0A4.5 4.5 0 1 1 14 9.5 4.49 4.49 0 0 1 9.5 14Z"/>
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Phone/tablet: overflow menu — nothing may disappear on small screens -->
|
||||
<button type="button" (click)="toggleMoreMenu()" class="app-icon-btn md:hidden focus-ring"
|
||||
[attr.aria-expanded]="moreMenuOpen()" aria-haspopup="true" aria-controls="mobile-more-menu"
|
||||
[attr.aria-label]="('menu.more' | t)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 8a2 2 0 1 0-2-2 2 2 0 0 0 2 2Zm0 2a2 2 0 1 0 2 2 2 2 0 0 0-2-2Zm0 6a2 2 0 1 0 2 2 2 2 0 0 0-2-2Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Desktop: themes bar toggle -->
|
||||
<button type="button" (click)="toggleThemesBar()"
|
||||
class="app-icon-btn hidden md:inline-flex focus-ring"
|
||||
[attr.aria-pressed]="themesBarVisible()"
|
||||
[attr.title]="themesBarVisible() ? ('themes.hideBar' | t : 'Masquer la bande des thèmes') : ('themes.showBar' | t : 'Afficher la bande des thèmes')"
|
||||
aria-label="Afficher ou masquer la bande des thèmes"
|
||||
class="p-2 rounded-full text-slate-200 hover:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
[attr.title]="themesBarVisible() ? ('themes.hideBar' | t) : ('themes.showBar' | t)"
|
||||
[attr.aria-label]="themesBarVisible() ? ('themes.hideBar' | t) : ('themes.showBar' | t)"
|
||||
[class.opacity-40]="!themesBarVisible()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17a5 5 0 1 1 0-10 5 5 0 0 1 0 10zm0-8a3 3 0 1 0 0 6 3 3 0 0 0 0-6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Provider context indicator placed between search and user area -->
|
||||
<div *ngIf="providerContextLabel()" class="flex items-center gap-1 text-xs">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded border shadow-sm gap-1"
|
||||
|
||||
<!-- Desktop: provider context badge -->
|
||||
<div *ngIf="providerContextLabel() as providerLabel"
|
||||
class="hidden lg:flex items-center gap-1 text-xs min-w-0">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded border shadow-sm gap-1 max-w-[12rem] truncate"
|
||||
[ngStyle]="getProviderColors(providerContext())">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<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>
|
||||
<span>{{ providerContextLabel() }}</span>
|
||||
<span class="truncate">{{ providerLabel }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-6 w-px bg-slate-700/60 mx-2"></div>
|
||||
|
||||
<!-- Auth / User Menu Area -->
|
||||
<div *ngIf="!user(); else userMenu" class="flex items-center gap-2">
|
||||
<a routerLink="/auth/login" class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-white">{{ 'btn.login' | t }}</a>
|
||||
<a routerLink="/auth/register" class="px-3 py-1 rounded bg-red-600 hover:bg-red-500 text-white">{{ 'btn.register' | t }}</a>
|
||||
</div>
|
||||
<ng-template #userMenu>
|
||||
<div class="relative" #userMenuContainer>
|
||||
<!-- Avatar Button -->
|
||||
<button (click)="toggleUserMenu()" class="flex items-center gap-2 p-1 rounded-full hover:bg-slate-700">
|
||||
<div class="h-8 w-8 rounded-full flex items-center justify-center text-sm font-semibold border"
|
||||
[ngClass]="{
|
||||
'bg-slate-200 text-slate-800 border-slate-300': isLightTheme(),
|
||||
'bg-slate-600 text-white border-slate-500': isDarkTheme(),
|
||||
'bg-slate-800 text-slate-200 border-slate-700': isBlackTheme(),
|
||||
'bg-blue-900 text-blue-100 border-blue-800': isBlueTheme()
|
||||
}">
|
||||
{{ (user()?.username || user()?.email || 'U') | slice:0:1 | uppercase }}
|
||||
</div>
|
||||
</button>
|
||||
<!-- Dropdown Menu -->
|
||||
@if (userMenuOpen()) {
|
||||
<div class="absolute right-0 mt-2 w-64 bg-slate-900 border border-slate-700 rounded-xl shadow-xl overflow-hidden z-50">
|
||||
<div class="px-4 py-3 border-b border-slate-700">
|
||||
<div class="font-semibold text-slate-100">{{ user()?.username || user()?.email }}</div>
|
||||
<div class="text-xs text-slate-400 truncate">{{ user()?.email }}</div>
|
||||
</div>
|
||||
<div class="py-1">
|
||||
<a routerLink="/account/preferences" class="block px-4 py-2 text-sm text-slate-200 hover:bg-slate-800">{{ 'menu.preferences' | t }}</a>
|
||||
<div class="px-4 py-2 text-xs uppercase tracking-wide text-slate-500">{{ 'menu.appearance' | t }}</div>
|
||||
<div class="px-3 pb-2 grid grid-cols-4 gap-2">
|
||||
<!-- Black -->
|
||||
<button (click)="setTheme('black')"
|
||||
[ngClass]="{ 'ring-2': currentTheme() === 'black', 'ring-red-500': currentTheme() === 'black' }"
|
||||
class="px-2 py-2 text-xs rounded bg-slate-800 hover:bg-slate-700 flex items-center justify-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="9"/></svg>
|
||||
<span>{{ 'theme.black' | t }}</span>
|
||||
</button>
|
||||
<!-- Dark -->
|
||||
<button (click)="setTheme('dark')"
|
||||
[ngClass]="{ 'ring-2': currentTheme() === 'dark', 'ring-red-500': currentTheme() === 'dark' }"
|
||||
class="px-2 py-2 text-xs rounded bg-slate-800 hover:bg-slate-700 flex items-center justify-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 1 0 9.79 9.79Z"/></svg>
|
||||
<span>{{ 'theme.dark' | t }}</span>
|
||||
</button>
|
||||
<!-- Blue -->
|
||||
<button (click)="setTheme('blue')"
|
||||
[ngClass]="{ 'ring-2': currentTheme() === 'blue', 'ring-red-500': currentTheme() === 'blue' }"
|
||||
class="px-2 py-2 text-xs rounded bg-slate-800 hover:bg-slate-700 flex items-center justify-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2s7 7.58 7 12a7 7 0 1 1-14 0C5 9.58 12 2 12 2Z"/></svg>
|
||||
<span>{{ 'theme.blue' | t }}</span>
|
||||
</button>
|
||||
<!-- Light -->
|
||||
<button (click)="setTheme('light')"
|
||||
[ngClass]="{ 'ring-2': currentTheme() === 'light', 'ring-red-500': currentTheme() === 'light' }"
|
||||
class="px-2 py-2 text-xs rounded bg-slate-800 hover:bg-slate-700 flex items-center justify-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 18a6 6 0 1 0 0-12 6 6 0 0 0 0 12Zm0-16a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V3a1 1 0 0 1 1-1Zm0 18a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1Zm9-9a1 1 0 0 1 1 1h-2a1 1 0 1 1 0-2h2a1 1 0 0 1-1 1ZM4 12a1 1 0 0 1 1-1H3a1 1 0 1 1 0 2h2a1 1 0 0 1-1-1Zm12.95 6.364a1 1 0 0 1 1.414 1.414l-1.415 1.415a1 1 0 1 1-1.414-1.415l1.415-1.414ZM7.05 4.222a1 1 0 1 1 1.414-1.415L9.88 4.222A1 1 0 0 1 8.465 5.636L7.05 4.222zm9.9-1.415a1 1 0 1 1 1.414 1.415L17.95 5.636A1 1 0 0 1 16.536 4.22l1.414-1.414ZM7.05 19.778 5.636 21.19a1 1 0 0 1-1.414-1.414L5.636 18.364A1 1 0 0 1 7.05 19.78Z"/></svg>
|
||||
<span>{{ 'theme.light' | t }}</span>
|
||||
</button>
|
||||
<!-- Desktop: auth / user menu -->
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<div class="h-6 w-px bg-slate-700/60 mx-1"></div>
|
||||
@if (!user()) {
|
||||
<a routerLink="/auth/login" class="px-3 py-2 rounded text-slate-100 hover:bg-slate-700 focus-ring">{{ 'btn.login' | t }}</a>
|
||||
<a routerLink="/auth/register" class="px-3 py-2 rounded bg-red-600 hover:bg-red-500 text-white focus-ring">{{ 'btn.register' | t }}</a>
|
||||
} @else {
|
||||
<div class="relative" #userMenuContainer>
|
||||
<button type="button" (click)="toggleUserMenu()" class="app-avatar-btn focus-ring" aria-haspopup="true"
|
||||
[attr.aria-expanded]="userMenuOpen()" [attr.aria-label]="user()?.username || user()?.email || ('menu.account' | t)">
|
||||
<span class="avatar-circle" [ngClass]="avatarClasses()">{{ avatarLetter() }}</span>
|
||||
</button>
|
||||
@if (userMenuOpen()) {
|
||||
<div class="absolute right-0 mt-2 w-[min(20rem,calc(100vw-1rem))] overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-xl z-50" role="menu">
|
||||
<div class="border-b border-slate-700 px-4 py-3">
|
||||
<div class="truncate font-semibold text-slate-100">{{ user()?.username || user()?.email }}</div>
|
||||
<div class="truncate text-xs text-slate-400">{{ user()?.email }}</div>
|
||||
</div>
|
||||
<div class="py-1">
|
||||
<a routerLink="/account/preferences" (click)="closeUserMenu()" role="menuitem" class="block px-4 py-3 text-sm text-slate-200 hover:bg-slate-800">{{ 'menu.preferences' | t }}</a>
|
||||
<div class="px-4 py-2 text-xs uppercase tracking-wide text-slate-500">{{ 'menu.appearance' | t }}</div>
|
||||
<div class="px-3 pb-3">
|
||||
<ng-container *ngTemplateOutlet="themeSwatches"></ng-container>
|
||||
</div>
|
||||
<button type="button" (click)="onLogout(); closeUserMenu()" role="menuitem" class="w-full px-4 py-3 text-left text-sm text-red-300 hover:bg-red-900/30">{{ 'menu.logout' | t }}</button>
|
||||
</div>
|
||||
<button (click)="onLogout()" class="w-full text-left px-4 py-2 text-sm text-red-300 hover:bg-red-900/30">{{ 'menu.logout' | t }}</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile search sheet: the same <app-search-box> instance is rendered here
|
||||
through CSS only when the user asks for it. Kept out of the flow so the
|
||||
page never reflows while typing. -->
|
||||
<div id="mobile-search-sheet" class="md:hidden border-t border-slate-700/60 bg-slate-900/95 backdrop-blur-sm px-2 pb-2"
|
||||
[class.hidden]="!mobileSearchOpen()">
|
||||
<div class="relative">
|
||||
<app-search-box [placeholder]="('search.placeholder' | t)"
|
||||
(submitted)="onSearchBoxSubmit($event)"></app-search-box>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile overflow menu -->
|
||||
<div *ngIf="moreMenuOpen()" id="mobile-more-menu"
|
||||
class="md:hidden absolute inset-x-0 top-full z-50 max-h-[calc(var(--app-vh)-var(--header-offset)-1rem)] overflow-y-auto overscroll-contain border-b border-slate-700 bg-slate-900 shadow-2xl"
|
||||
[style.paddingLeft]="'var(--safe-left)'" [style.paddingRight]="'var(--safe-right)'">
|
||||
|
||||
@if (providerContextLabel(); as providerLabel) {
|
||||
<div class="flex items-center justify-between gap-3 border-b border-slate-800 px-4 py-3">
|
||||
<span class="text-xs uppercase tracking-wide text-slate-400">{{ 'label.provider' | t }}</span>
|
||||
<span class="inline-flex items-center gap-1 rounded border px-2 py-1 text-xs max-w-[60%] truncate"
|
||||
[ngStyle]="getProviderColors(providerContext())">
|
||||
<span class="truncate">{{ providerLabel }}</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<nav class="py-1" [attr.aria-label]="('menu.navigation' | t)">
|
||||
<button type="button" (click)="toggleThemesBar(); closeMoreMenu()"
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-left text-sm text-slate-200 hover:bg-slate-800">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0 opacity-80" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17a5 5 0 1 1 0-10 5 5 0 0 1 0 10zm0-8a3 3 0 1 0 0 6 3 3 0 0 0 0-6z"/>
|
||||
</svg>
|
||||
<span class="flex-1">{{ themesBarVisible() ? ('themes.hideBar' | t) : ('themes.showBar' | t) }}</span>
|
||||
</button>
|
||||
|
||||
<a routerLink="/account/preferences" (click)="closeMoreMenu()" class="flex items-center gap-3 px-4 py-3 text-sm text-slate-200 hover:bg-slate-800">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0 opacity-80" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 15.5A3.5 3.5 0 1 1 15.5 12 3.5 3.5 0 0 1 12 15.5Zm7.43-2.53a7.6 7.6 0 0 0 0-1.94l2.05-1.58a.5.5 0 0 0 .12-.64l-1.94-3.36a.5.5 0 0 0-.6-.22l-2.43.98a7.3 7.3 0 0 0-1.67-.97l-.37-2.58A.49.49 0 0 0 14 2h-4a.49.49 0 0 0-.49.42l-.37 2.58a7.7 7.7 0 0 0-1.67.97l-2.43-.98a.5.5 0 0 0-.6.22L2.07 8.58a.5.5 0 0 0 .12.64l2.05 1.58a7.9 7.9 0 0 0 0 1.94l-2.05 1.58a.5.5 0 0 0-.12.64l1.94 3.36a.5.5 0 0 0 .6.22l2.43-.98c.52.4 1.08.73 1.67.97l.37 2.58A.49.49 0 0 0 10 22h4a.49.49 0 0 0 .49-.42l.37-2.58c.59-.24 1.15-.57 1.67-.97l2.43.98a.5.5 0 0 0 .6-.22l1.94-3.36a.5.5 0 0 0-.12-.64Z"/></svg>
|
||||
<span class="flex-1">{{ 'menu.preferences' | t }}</span>
|
||||
</a>
|
||||
|
||||
@if (user()) {
|
||||
<a routerLink="/account/history" (click)="closeMoreMenu()" class="flex items-center gap-3 px-4 py-3 text-sm text-slate-200 hover:bg-slate-800">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0 opacity-80" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 8a1 1 0 0 1 1 1v3.38l2.32 1.34a1 1 0 1 1-1 1.74l-2.82-1.63A1 1 0 0 1 11 13V9a1 1 0 0 1 1-1Zm0-6a10 10 0 1 0 10 10 10.01 10.01 0 0 0-10-10Z"/></svg>
|
||||
<span class="flex-1">{{ 'nav.history' | t }}</span>
|
||||
</a>
|
||||
<a routerLink="/library/liked" (click)="closeMoreMenu()" class="flex items-center gap-3 px-4 py-3 text-sm text-slate-200 hover:bg-slate-800">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0 opacity-80" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 21s-6.716-4.248-9.193-6.725A6 6 0 0 1 12 5.414 6 6 0 0 1 21.193 14.275C18.716 16.752 12 21 12 21Z"/></svg>
|
||||
<span class="flex-1">{{ 'nav.liked' | t }}</span>
|
||||
</a>
|
||||
<a routerLink="/library/downloads" (click)="closeMoreMenu()" class="flex items-center gap-3 px-4 py-3 text-sm text-slate-200 hover:bg-slate-800">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0 opacity-80" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 3v10.17l3.59-3.58L17 11l-5 5-5-5 1.41-1.41L12 13.17V3Zm-7 15h14v2H5v-2Z"/></svg>
|
||||
<span class="flex-1">{{ 'nav.downloads' | t }}</span>
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<!-- Appearance is offered to everyone, signed in or not: on phones this is
|
||||
the only place a theme can be changed. -->
|
||||
<div class="border-t border-slate-800 px-4 py-3">
|
||||
<div class="mb-2 text-xs uppercase tracking-wide text-slate-500">{{ 'menu.appearance' | t }}</div>
|
||||
<ng-container *ngTemplateOutlet="themeSwatches"></ng-container>
|
||||
</div>
|
||||
|
||||
@if (user()) {
|
||||
<div class="border-t border-slate-800 px-4 py-3">
|
||||
<div class="mb-2 flex items-center gap-3">
|
||||
<span class="avatar-circle" [ngClass]="avatarClasses()">{{ avatarLetter() }}</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-semibold text-slate-100">{{ user()?.username || user()?.email }}</span>
|
||||
<span class="block truncate text-xs text-slate-400">{{ user()?.email }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" (click)="onLogout(); closeMoreMenu()" class="w-full rounded px-3 py-3 text-left text-sm text-red-300 hover:bg-red-900/30">
|
||||
{{ 'menu.logout' | t }}
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="grid grid-cols-2 gap-2 border-t border-slate-800 p-3">
|
||||
<a routerLink="/auth/login" (click)="closeMoreMenu()" class="rounded bg-slate-800 px-3 py-3 text-center text-sm text-white hover:bg-slate-700">{{ 'btn.login' | t }}</a>
|
||||
<a routerLink="/auth/register" (click)="closeMoreMenu()" class="rounded bg-red-600 px-3 py-3 text-center text-sm text-white hover:bg-red-500">{{ 'btn.register' | t }}</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Backdrop closing the overflow menu / search sheet on outside tap -->
|
||||
@if (moreMenuOpen() || mobileSearchOpen()) {
|
||||
<div class="fixed inset-0 z-40 md:hidden bg-black/50" (click)="closeOverlays()" aria-hidden="true"></div>
|
||||
}
|
||||
|
||||
<!-- ======================= Shared templates ======================= -->
|
||||
|
||||
<ng-template #themeSwatches>
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
|
||||
<button type="button" (click)="setTheme('black')"
|
||||
[attr.aria-pressed]="currentTheme() === 'black'"
|
||||
[ngClass]="{ 'ring-2 ring-red-500': currentTheme() === 'black' }"
|
||||
class="flex items-center justify-center gap-1 rounded bg-slate-800 px-2 py-3 text-xs hover:bg-slate-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="12" cy="12" r="9"/></svg>
|
||||
<span class="truncate">{{ 'theme.black' | t }}</span>
|
||||
</button>
|
||||
<button type="button" (click)="setTheme('dark')"
|
||||
[attr.aria-pressed]="currentTheme() === 'dark'"
|
||||
[ngClass]="{ 'ring-2 ring-red-500': currentTheme() === 'dark' }"
|
||||
class="flex items-center justify-center gap-1 rounded bg-slate-800 px-2 py-3 text-xs hover:bg-slate-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 1 0 9.79 9.79Z"/></svg>
|
||||
<span class="truncate">{{ 'theme.dark' | t }}</span>
|
||||
</button>
|
||||
<button type="button" (click)="setTheme('blue')"
|
||||
[attr.aria-pressed]="currentTheme() === 'blue'"
|
||||
[ngClass]="{ 'ring-2 ring-red-500': currentTheme() === 'blue' }"
|
||||
class="flex items-center justify-center gap-1 rounded bg-slate-800 px-2 py-3 text-xs hover:bg-slate-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2s7 7.58 7 12a7 7 0 1 1-14 0C5 9.58 12 2 12 2Z"/></svg>
|
||||
<span class="truncate">{{ 'theme.blue' | t }}</span>
|
||||
</button>
|
||||
<button type="button" (click)="setTheme('light')"
|
||||
[attr.aria-pressed]="currentTheme() === 'light'"
|
||||
[ngClass]="{ 'ring-2 ring-red-500': currentTheme() === 'light' }"
|
||||
class="flex items-center justify-center gap-1 rounded bg-slate-800 px-2 py-3 text-xs hover:bg-slate-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 18a6 6 0 1 0 0-12 6 6 0 0 0 0 12Zm0-16a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V3a1 1 0 0 1 1-1Zm0 18a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1Zm9-9a1 1 0 0 1 1 1h-2a1 1 0 1 1 0-2h2a1 1 0 0 1-1 1ZM4 12a1 1 0 0 1 1-1H3a1 1 0 1 1 0 2h2a1 1 0 0 1-1-1Zm12.95 6.364a1 1 0 0 1 1.414 1.414l-1.415 1.415a1 1 0 1 1-1.414-1.415l1.415-1.414ZM7.05 4.222a1 1 0 1 1 1.414-1.415L9.88 4.222A1 1 0 0 1 8.465 5.636L7.05 4.222Zm9.9-1.415a1 1 0 1 1 1.414 1.415L17.95 5.636A1 1 0 0 1 16.536 4.22l1.414-1.414ZM7.05 19.778 5.636 21.19a1 1 0 0 1-1.414-1.414L5.636 18.364A1 1 0 0 1 7.05 19.78Z"/></svg>
|
||||
<span class="truncate">{{ 'theme.light' | t }}</span>
|
||||
</button>
|
||||
<button type="button" (click)="setTheme('system')"
|
||||
[attr.aria-pressed]="currentTheme() === 'system'"
|
||||
[ngClass]="{ 'ring-2 ring-red-500': currentTheme() === 'system' }"
|
||||
class="flex items-center justify-center gap-1 rounded bg-slate-800 px-2 py-3 text-xs hover:bg-slate-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2a10 10 0 1 0 10 10h-2a8 8 0 1 1-8-8V2Z"/></svg>
|
||||
<span class="truncate">{{ 'theme.system' | t }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- PeerTube Instance Editor Modal -->
|
||||
<div *ngIf="editingPeerTubeInstances()" class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
|
||||
<div class="bg-gray-800 rounded-lg p-6 w-full max-w-md">
|
||||
<h2 class="text-xl font-bold mb-4">Edit PeerTube Instances</h2>
|
||||
<p class="text-sm text-gray-400 mb-4">Enter one instance domain per line.</p>
|
||||
<textarea [(ngModel)]="peerTubeInstancesInput" class="w-full h-48 bg-gray-900 text-white rounded p-2 focus:outline-none focus:ring-2 focus:ring-red-500"></textarea>
|
||||
<div class="mt-4 flex justify-end gap-3">
|
||||
<button (click)="editingPeerTubeInstances.set(false)" class="px-4 py-2 rounded bg-gray-600 hover:bg-gray-500">Cancel</button>
|
||||
<button (click)="savePeerTubeInstances()" class="px-4 py-2 rounded bg-red-600 hover:bg-red-500">Save</button>
|
||||
<div *ngIf="editingPeerTubeInstances()" class="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4" (click)="editingPeerTubeInstances.set(false)">
|
||||
<div class="dialog-scroll w-full max-w-md rounded-lg bg-gray-800 p-5 sm:p-6" (click)="$event.stopPropagation()">
|
||||
<h2 class="mb-3 text-lg font-bold sm:text-xl">Edit PeerTube Instances</h2>
|
||||
<p class="mb-4 text-sm text-gray-400">Enter one instance domain per line.</p>
|
||||
<textarea [(ngModel)]="peerTubeInstancesInput" class="h-48 w-full rounded bg-gray-900 p-2 text-white focus:outline-none focus:ring-2 focus:ring-red-500"></textarea>
|
||||
<div class="mt-4 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<button type="button" (click)="editingPeerTubeInstances.set(false)" class="px-4 py-2 rounded bg-gray-600 hover:bg-gray-500">Cancel</button>
|
||||
<button type="button" (click)="savePeerTubeInstances()" class="px-4 py-2 rounded bg-red-600 hover:bg-red-500">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal, Output, EventEmitter, HostListener, ViewChild, ElementRef } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal, Input, Output, EventEmitter, HostListener, ViewChild, ViewChildren, QueryList, ElementRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterLink, NavigationEnd } from '@angular/router';
|
||||
import { InstanceService, Provider } from '../../services/instance.service';
|
||||
@@ -35,8 +35,13 @@ export class HeaderComponent {
|
||||
private themes = inject(ThemesService);
|
||||
private history = inject(HistoryService);
|
||||
|
||||
@ViewChild('searchInput', { static: false }) searchInputRef?: ElementRef<HTMLInputElement>;
|
||||
@ViewChild('userMenuContainer', { static: false }) userMenuContainerRef?: ElementRef<HTMLElement>;
|
||||
// Two <app-search-box> instances exist (desktop slot + mobile sheet); only one
|
||||
// is ever visible, so focus must target the visible one.
|
||||
@ViewChildren(SearchBoxComponent) searchBoxes!: QueryList<SearchBoxComponent>;
|
||||
|
||||
/** Reflects the mobile drawer state so the burger exposes `aria-expanded`. */
|
||||
@Input() drawerOpen = false;
|
||||
|
||||
searchQuery = '';
|
||||
editingPeerTubeInstances = signal(false);
|
||||
@@ -116,9 +121,45 @@ export class HeaderComponent {
|
||||
if (theme) qp.theme = theme;
|
||||
try { console.debug('[Header] onSearchBoxSubmit -> navigate', qp); } catch {}
|
||||
this.suggestionsOpen.set(false);
|
||||
this.closeOverlays();
|
||||
this.router.navigate(['/search'], { queryParams: qp });
|
||||
}
|
||||
|
||||
// --- Mobile chrome actions ----------------------------------------------
|
||||
toggleMobileSearch(): void {
|
||||
const next = !this.mobileSearchOpen();
|
||||
this.mobileSearchOpen.set(next);
|
||||
if (next) {
|
||||
this.moreMenuOpen.set(false);
|
||||
// Focus once the sheet is actually laid out, otherwise the mobile
|
||||
// keyboard never opens.
|
||||
setTimeout(() => this.focusSearch(), 60);
|
||||
}
|
||||
}
|
||||
|
||||
toggleMoreMenu(): void {
|
||||
const next = !this.moreMenuOpen();
|
||||
this.moreMenuOpen.set(next);
|
||||
if (next) {
|
||||
this.userMenuOpen.set(false);
|
||||
this.mobileSearchOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
closeMoreMenu(): void {
|
||||
this.moreMenuOpen.set(false);
|
||||
}
|
||||
|
||||
closeOverlays(): void {
|
||||
this.moreMenuOpen.set(false);
|
||||
this.mobileSearchOpen.set(false);
|
||||
this.userMenuOpen.set(false);
|
||||
}
|
||||
|
||||
closeUserMenu(): void {
|
||||
this.userMenuOpen.set(false);
|
||||
}
|
||||
|
||||
// Generate query suggestions based on the current text and provider
|
||||
private generateQuerySuggestions(q: string, provider: string | null): string[] {
|
||||
const base = (q || '').trim();
|
||||
@@ -216,6 +257,29 @@ export class HeaderComponent {
|
||||
authError = signal<string | null>(null);
|
||||
userMenuOpen = signal(false);
|
||||
|
||||
// --- Mobile chrome -------------------------------------------------------
|
||||
// Slide-down search sheet (phones). Desktop keeps the search box inline, so
|
||||
// the same <app-search-box> is only *positioned* differently by CSS.
|
||||
mobileSearchOpen = signal(false);
|
||||
// Overflow menu: guarantees that themes-bar toggle, provider badge, account
|
||||
// and theme switching stay reachable below the `md` breakpoint.
|
||||
moreMenuOpen = signal(false);
|
||||
|
||||
avatarLetter = computed(() => {
|
||||
const u: any = this.user();
|
||||
return String(u?.username || u?.email || 'U').charAt(0).toUpperCase();
|
||||
});
|
||||
|
||||
avatarClasses = computed(() => {
|
||||
const t = this.resolveThemeSlug(this.currentTheme());
|
||||
switch (t) {
|
||||
case 'light': return 'bg-slate-200 text-slate-800 border-slate-300';
|
||||
case 'dark': return 'bg-slate-600 text-white border-slate-500';
|
||||
case 'blue': return 'bg-blue-900 text-blue-100 border-blue-800';
|
||||
default: return 'bg-slate-800 text-slate-200 border-slate-700';
|
||||
}
|
||||
});
|
||||
|
||||
// Sidebar toggle (emits to parent AppComponent)
|
||||
@Output() menuToggle = new EventEmitter<void>();
|
||||
|
||||
@@ -402,6 +466,10 @@ export class HeaderComponent {
|
||||
this.router.events.subscribe(evt => {
|
||||
if (evt instanceof NavigationEnd) {
|
||||
this.updateProviderContextFromUrl(evt.urlAfterRedirects || evt.url);
|
||||
// Any navigation dismisses the transient mobile panels.
|
||||
this.moreMenuOpen.set(false);
|
||||
this.mobileSearchOpen.set(false);
|
||||
this.userMenuOpen.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -470,35 +538,57 @@ export class HeaderComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** The <app-search-box> whose host element is currently visible. */
|
||||
private visibleSearchBox(): SearchBoxComponent | undefined {
|
||||
const boxes = this.searchBoxes?.toArray() || [];
|
||||
return boxes.find((b) => {
|
||||
const host = (b as any)?.el?.nativeElement as HTMLElement | undefined;
|
||||
return !!host && host.offsetParent !== null;
|
||||
}) || boxes[boxes.length - 1];
|
||||
}
|
||||
|
||||
focusSearch() {
|
||||
try {
|
||||
const el = this.searchInputRef?.nativeElement;
|
||||
if (el) { el.focus(); el.select(); }
|
||||
const el = this.visibleSearchBox()?.focusInput();
|
||||
if (el && el.offsetParent !== null) { el.focus(); el.select(); }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
handleGlobalKeydown(ev: KeyboardEvent) {
|
||||
const isInput = (ev.target as HTMLElement)?.closest('input, textarea, [contenteditable="true"]');
|
||||
|
||||
if (ev.key === 'Escape') {
|
||||
// Transient UI closes top-down: overflow menu > search sheet > user menu.
|
||||
if (this.moreMenuOpen() || this.mobileSearchOpen() || this.userMenuOpen()) {
|
||||
ev.preventDefault();
|
||||
this.closeOverlays();
|
||||
return;
|
||||
}
|
||||
const el = this.visibleSearchBox()?.focusInput();
|
||||
if (el && document.activeElement === el) (document.activeElement as HTMLElement)?.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+K focuses search
|
||||
if ((ev.key === 'k' || ev.key === 'K') && (ev.ctrlKey || ev.metaKey)) {
|
||||
ev.preventDefault();
|
||||
this.focusSearch();
|
||||
if (!this.mobileSearchOpen() && typeof window !== 'undefined' && window.innerWidth < 768) {
|
||||
this.mobileSearchOpen.set(true);
|
||||
this.moreMenuOpen.set(false);
|
||||
setTimeout(() => this.focusSearch(), 60);
|
||||
} else {
|
||||
this.focusSearch();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// '/' focuses search when not typing in another input
|
||||
if (ev.key === '/' && !isInput) {
|
||||
|
||||
// '/' focuses search when not typing in another input (desktop shortcut)
|
||||
if (ev.key === '/' && !isInput && typeof window !== 'undefined' && window.innerWidth >= 768) {
|
||||
ev.preventDefault();
|
||||
this.focusSearch();
|
||||
return;
|
||||
}
|
||||
// Escape blurs search
|
||||
if (ev.key === 'Escape') {
|
||||
const el = this.searchInputRef?.nativeElement;
|
||||
if (el && document.activeElement === el) {
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onProviderChange(event: Event) {
|
||||
@@ -508,15 +598,38 @@ export class HeaderComponent {
|
||||
|
||||
toggleUserMenu() {
|
||||
this.userMenuOpen.update(v => !v);
|
||||
if (this.userMenuOpen()) {
|
||||
this.moreMenuOpen.set(false);
|
||||
this.mobileSearchOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
onDocumentClick(ev: MouseEvent) {
|
||||
if (!this.userMenuOpen()) return;
|
||||
const container = this.userMenuContainerRef?.nativeElement;
|
||||
const target = ev.target as Node | null;
|
||||
if (container && target && !container.contains(target)) {
|
||||
this.userMenuOpen.set(false);
|
||||
if (this.userMenuOpen()) {
|
||||
const container = this.userMenuContainerRef?.nativeElement;
|
||||
const target = ev.target as Node | null;
|
||||
if (container && target && !container.contains(target)) {
|
||||
this.userMenuOpen.set(false);
|
||||
}
|
||||
}
|
||||
// The overflow menu has no container ref to test against: it is only ever
|
||||
// open on phones, where any click outside it (and outside its trigger)
|
||||
// must dismiss it.
|
||||
if (this.moreMenuOpen()) {
|
||||
const target = ev.target as HTMLElement | null;
|
||||
if (target && !target.closest('#mobile-more-menu') && !target.closest('[aria-controls="mobile-more-menu"]')) {
|
||||
this.moreMenuOpen.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Leaving the page (or resizing up to desktop) must not strand a transient panel.
|
||||
@HostListener('document:resize')
|
||||
onResize() {
|
||||
if (typeof window !== 'undefined' && window.innerWidth >= 768) {
|
||||
this.mobileSearchOpen.set(false);
|
||||
this.moreMenuOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">{{ 'home.trending' | t }}</h2>
|
||||
<h2 class="mb-6 border-l-4 border-red-500 pl-4 text-2xl font-bold text-slate-100 sm:text-3xl">{{ 'home.trending' | t }}</h2>
|
||||
@if (notice()) {
|
||||
<div class="mb-4 bg-amber-900/40 border border-amber-600 text-amber-200 p-3 rounded">
|
||||
{{ notice() }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="container mx-auto p-4 sm:p-6">
|
||||
<h1 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">🧭 Guide d'utilisation</h1>
|
||||
<h1 class="mb-6 border-l-4 border-red-500 pl-4 text-2xl font-bold text-slate-100 sm:text-3xl">🧭 Guide d'utilisation</h1>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<!-- Introduction -->
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<section class="mx-auto max-w-4xl px-4 py-6">
|
||||
<a routerLink="/library/downloads"
|
||||
class="inline-flex items-center gap-2 text-sm text-slate-300 hover:text-white transition">
|
||||
class="chip inline-flex items-center gap-2 rounded px-2 text-sm text-slate-300 transition hover:text-white">
|
||||
<span aria-hidden="true">←</span> {{ 'downloads.back' | t }}
|
||||
</a>
|
||||
|
||||
@@ -31,18 +31,18 @@
|
||||
@if (isAudio()) {
|
||||
<audio [src]="src" controls preload="metadata" class="w-full"></audio>
|
||||
} @else {
|
||||
<video [src]="src" controls preload="metadata" playsinline class="max-h-[70vh] w-full"></video>
|
||||
<video [src]="src" controls preload="metadata" playsinline class="max-h-[min(70dvh,100%)] w-full"></video>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<a [routerLink]="['/watch', j.provider, j.videoId]" queryParamsHandling="merge"
|
||||
class="rounded-lg border border-slate-600 px-3 py-1.5 text-xs text-slate-200 hover:bg-slate-700 transition">
|
||||
class="chip rounded-lg border border-slate-600 px-3 py-2.5 text-xs text-slate-200 transition hover:bg-slate-700">
|
||||
Watch
|
||||
</a>
|
||||
<button type="button" (click)="downloadCopy()" [disabled]="downloading()"
|
||||
class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-500 disabled:opacity-50 transition">
|
||||
class="chip rounded-lg bg-emerald-600 px-3 py-2.5 text-xs font-semibold text-white transition hover:bg-emerald-500 disabled:opacity-50">
|
||||
{{ 'downloads.getFile' | t }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<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 class="flex min-w-0 items-center gap-3">
|
||||
<a [routerLink]="['/library/subscriptions']" aria-label="Retour aux abonnements" class="chip shrink-0 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-slate-200 transition hover:bg-slate-700">‹</a>
|
||||
<h2 class="min-w-0 text-2xl font-bold text-slate-100 sm:text-3xl">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">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="container mx-auto p-4 sm:p-6 max-w-6xl">
|
||||
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4 flex items-center">
|
||||
<h2 class="mb-6 flex items-center border-l-4 border-red-500 pl-4 text-2xl font-bold text-slate-100 sm:text-3xl">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-7 w-7 mr-2 text-red-400">
|
||||
<path d="M12 21s-6.716-4.248-9.193-6.725A6 6 0 0 1 12 5.414 6 6 0 0 1 21.193 14.275C18.716 16.752 12 21 12 21z" />
|
||||
</svg>
|
||||
|
||||
@@ -11,10 +11,10 @@ import { AuthService } from '../../../services/auth.service';
|
||||
imports: [CommonModule, RouterLink],
|
||||
template: `
|
||||
<div class="container mx-auto p-4 sm:p-6">
|
||||
<div class="flex items-center justify-between gap-3 mb-4">
|
||||
<a routerLink="/library/playlists" class="text-slate-300 hover:text-slate-100">← Retour</a>
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<a routerLink="/library/playlists" class="chip inline-flex items-center rounded px-2 text-slate-300 hover:text-slate-100">← Retour</a>
|
||||
<div class="flex items-center gap-2" *ngIf="dirty() && isOwner()">
|
||||
<button (click)="saveOrder()" [disabled]="saving()" class="px-4 py-2 rounded bg-red-600 hover:bg-red-500 text-white font-semibold">
|
||||
<button type="button" (click)="saveOrder()" [disabled]="saving()" class="chip px-4 py-3 rounded bg-red-600 hover:bg-red-500 text-white font-semibold sm:py-2">
|
||||
{{ saving() ? 'Enregistrement...' : 'Enregistrer l\'ordre' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -57,18 +57,18 @@ import { AuthService } from '../../../services/auth.service';
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div *ngFor="let it of items(); let i = index" class="flex items-center gap-3 p-2 rounded border border-slate-700 bg-slate-800/60">
|
||||
<div *ngFor="let it of items(); let i = index" class="flex flex-wrap items-center gap-3 p-2 rounded border border-slate-700 bg-slate-800/60 sm:flex-nowrap">
|
||||
<div class="w-28 flex-shrink-0">
|
||||
<img [src]="it.thumbnail || DEFAULT_PLAYLIST_IMAGE" [alt]="it.title || 'Video thumbnail'" class="w-28 h-16 object-cover rounded">
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<a [routerLink]="['/watch', it.videoId]" [queryParams]="{ p: it.provider }" class="font-semibold text-slate-100 hover:text-red-400 line-clamp-2">{{ it.title || it.videoId }}</a>
|
||||
<div class="min-w-0 flex-1 basis-[calc(100%_-_7rem)]">
|
||||
<a [routerLink]="['/watch', it.videoId]" [queryParams]="{ p: it.provider }" class="break-anywhere font-semibold text-slate-100 hover:text-red-400 line-clamp-2">{{ it.title || it.videoId }}</a>
|
||||
<div class="text-xs text-slate-400 mt-1">{{ it.provider }} • Pos. {{ i + 1 }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2" *ngIf="isOwner()">
|
||||
<button (click)="moveUp(i)" [disabled]="i===0" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm">↑</button>
|
||||
<button (click)="moveDown(i)" [disabled]="i===items().length-1" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm">↓</button>
|
||||
<button (click)="removeItem(it)" class="px-2 py-1 rounded bg-red-700 hover:bg-red-600 text-white text-sm">Retirer</button>
|
||||
<div class="ml-auto flex w-full items-center justify-end gap-2 sm:ml-0 sm:w-auto" *ngIf="isOwner()">
|
||||
<button type="button" (click)="moveUp(i)" [disabled]="i===0" aria-label="Monter" class="chip px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm disabled:opacity-40">↑</button>
|
||||
<button type="button" (click)="moveDown(i)" [disabled]="i===items().length-1" aria-label="Descendre" class="chip px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm disabled:opacity-40">↓</button>
|
||||
<button type="button" (click)="removeItem(it)" class="chip px-3 py-2 rounded bg-red-700 hover:bg-red-600 text-white text-sm">Retirer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<div class="container mx-auto p-4 sm:p-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
|
||||
<h2 class="text-3xl font-bold text-slate-100 border-l-4 border-red-500 pl-4">Listes de lecture</h2>
|
||||
<div class="flex items-center gap-3 w-full sm:w-auto">
|
||||
<input type="text" class="flex-1 sm:w-64 bg-slate-900 text-slate-100 border border-slate-700 rounded px-3 py-2"
|
||||
<!-- Wraps: at the `md` breakpoint the sidebar takes 256px, so the title and
|
||||
the toolbar must be allowed to share a row only when they truly fit. -->
|
||||
<div class="mb-6 flex flex-col flex-wrap gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<h2 class="border-l-4 border-red-500 pl-4 text-2xl font-bold text-slate-100 sm:text-3xl">Listes de lecture</h2>
|
||||
<div class="flex w-full min-w-0 flex-col gap-3 sm:flex-row sm:items-center lg:w-auto">
|
||||
<input type="text" class="min-w-0 flex-1 bg-slate-900 text-slate-100 border border-slate-700 rounded px-3 py-3 sm:w-56 sm:py-2 lg:w-64"
|
||||
placeholder="Rechercher..."
|
||||
[value]="searchQuery()"
|
||||
(input)="onSearchChange($event)"
|
||||
/>
|
||||
<button (click)="openCreate()" class="bg-red-600 hover:bg-red-500 text-white font-semibold px-4 py-2 rounded">
|
||||
<button (click)="openCreate()" class="chip bg-red-600 hover:bg-red-500 text-white font-semibold px-4 py-3 rounded sm:py-2">
|
||||
Nouvelle playlist
|
||||
</button>
|
||||
</div>
|
||||
@@ -47,14 +49,14 @@
|
||||
{{ pl.isPrivate ? 'Privée' : 'Publique' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-slate-400 flex items-center justify-between">
|
||||
<div class="mt-2 flex flex-wrap items-center justify-between gap-x-3 text-sm text-slate-400">
|
||||
<span>{{ pl.itemsCount || 0 }} vidéo(s)</span>
|
||||
<span>MAJ: {{ (pl.updatedAt || '').slice(0,10) }}</span>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
@if (currentUser() && pl.userId === currentUser()?.id) {
|
||||
<button (click)="openEdit(pl)" class="px-3 py-1.5 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm">Modifier</button>
|
||||
<button (click)="deletePlaylist(pl)" class="px-3 py-1.5 rounded bg-red-700 hover:bg-red-600 text-white text-sm">Supprimer</button>
|
||||
<button (click)="openEdit(pl)" class="chip px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm">Modifier</button>
|
||||
<button (click)="deletePlaylist(pl)" class="chip px-3 py-2 rounded bg-red-700 hover:bg-red-600 text-white text-sm">Supprimer</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,10 +67,10 @@
|
||||
|
||||
@if (modalOpen()) {
|
||||
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" role="dialog" aria-modal="true">
|
||||
<div class="w-full max-w-lg bg-slate-900 border border-slate-700 rounded-lg shadow-xl">
|
||||
<div class="dialog-scroll w-full max-w-lg bg-slate-900 border border-slate-700 rounded-lg shadow-xl">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-slate-100">{{ editId() ? 'Modifier la playlist' : 'Nouvelle playlist' }}</h3>
|
||||
<button (click)="closeModal()" class="text-slate-400 hover:text-slate-200">✕</button>
|
||||
<button type="button" (click)="closeModal()" class="app-icon-btn shrink-0 text-slate-400 hover:text-slate-200" aria-label="Fermer">✕</button>
|
||||
</div>
|
||||
<div class="p-4 space-y-4">
|
||||
<div>
|
||||
@@ -92,8 +94,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 border-t border-slate-700 flex items-center justify-end gap-3">
|
||||
<button (click)="closeModal()" class="px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" [disabled]="busySave()">Annuler</button>
|
||||
<button (click)="savePlaylist()" class="px-4 py-2 rounded bg-red-600 hover:bg-red-500 text-white font-semibold disabled:opacity-60" [disabled]="busySave() || !formTitle().trim()">
|
||||
<button type="button" (click)="closeModal()" class="chip px-4 py-3 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 sm:py-2" [disabled]="busySave()">Annuler</button>
|
||||
<button type="button" (click)="savePlaylist()" class="chip px-4 py-3 rounded bg-red-600 hover:bg-red-500 text-white font-semibold disabled:opacity-60 sm:py-2" [disabled]="busySave() || !formTitle().trim()">
|
||||
{{ busySave() ? 'Enregistrement...' : 'Enregistrer' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="container mx-auto p-4 sm:p-6">
|
||||
<div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold text-slate-100">Abonnements</h2>
|
||||
<h2 class="text-2xl font-bold text-slate-100 sm:text-3xl">Abonnements</h2>
|
||||
<p class="text-sm text-slate-400">Organisez vos chaînes suivies en groupes thématiques</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -154,29 +154,31 @@
|
||||
class="cursor-pointer rounded-xl border border-slate-800 bg-slate-900/60 p-4 shadow-lg shadow-black/30 transition hover:border-slate-600"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="flex h-11 w-11 items-center justify-center rounded-lg text-xl"
|
||||
class="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-xl"
|
||||
[style.background-color]="group.color + '26'"
|
||||
[style.border]="'1px solid ' + group.color + '66'"
|
||||
>{{ group.icon }}</span>
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-100">{{ group.name }}</h3>
|
||||
<div class="min-w-0">
|
||||
<h3 class="truncate font-semibold text-slate-100">{{ group.name }}</h3>
|
||||
<p class="text-xs text-slate-400">{{ group.channelCount }} chaîne(s)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1" (click)="$event.stopPropagation()">
|
||||
<div class="flex shrink-0 gap-1" (click)="$event.stopPropagation()">
|
||||
<button
|
||||
type="button"
|
||||
(click)="openEditGroup(group, $event)"
|
||||
class="rounded px-2 py-1 text-xs text-slate-400 hover:bg-slate-800 hover:text-white"
|
||||
class="chip rounded px-3 py-2 text-xs text-slate-400 hover:bg-slate-800 hover:text-white"
|
||||
title="Modifier le groupe"
|
||||
aria-label="Modifier le groupe"
|
||||
>✏️</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="deleteGroup(group, $event)"
|
||||
class="rounded px-2 py-1 text-xs text-slate-400 hover:bg-slate-800 hover:text-red-300"
|
||||
class="chip rounded px-3 py-2 text-xs text-slate-400 hover:bg-slate-800 hover:text-red-300"
|
||||
title="Supprimer le groupe"
|
||||
aria-label="Supprimer le groupe"
|
||||
>🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -303,7 +305,7 @@
|
||||
*ngIf="filteredItems().length > 0"
|
||||
type="button"
|
||||
(click)="toggleSelectAll()"
|
||||
class="text-xs text-slate-400 hover:text-white"
|
||||
class="chip px-2 text-xs text-slate-400 hover:text-white"
|
||||
>
|
||||
{{ selectedCount() > 0 ? 'Tout désélectionner' : 'Tout sélectionner' }}
|
||||
</button>
|
||||
@@ -345,25 +347,25 @@
|
||||
class="flex flex-col gap-4 py-4 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<ng-container *ngIf="item.channel as channel; else subscriptionFallback">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="isSelected(item.subscriptionId)"
|
||||
(change)="toggleSelect(item.subscriptionId)"
|
||||
class="h-4 w-4 shrink-0 accent-red-500"
|
||||
class="h-5 w-5 shrink-0 accent-red-500"
|
||||
title="Sélectionner pour action en lot"
|
||||
/>
|
||||
<a [routerLink]="['/channel', channel.provider, channel.externalId]" title="Ouvrir la page de la chaîne">
|
||||
<a [routerLink]="['/channel', channel.provider, channel.externalId]" title="Ouvrir la page de la chaîne" class="shrink-0">
|
||||
<img
|
||||
[src]="channel.avatarUrl || 'https://placehold.co/80x80/png'"
|
||||
[alt]="channel.title || channel.handle || 'Avatar'"
|
||||
class="h-16 w-16 rounded-full border border-slate-800 object-cover hover:ring-2 hover:ring-red-500/60"
|
||||
class="h-14 w-14 rounded-full border border-slate-800 object-cover hover:ring-2 hover:ring-red-500/60 sm:h-16 sm:w-16"
|
||||
/>
|
||||
</a>
|
||||
<div class="space-y-1">
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<a [routerLink]="['/channel', channel.provider, channel.externalId]"
|
||||
class="text-lg font-semibold text-slate-100 hover:text-red-300">
|
||||
class="min-w-0 break-anywhere text-base font-semibold text-slate-100 hover:text-red-300 sm:text-lg">
|
||||
{{ channel.title || channel.handle || 'Chaîne inconnue' }}
|
||||
</a>
|
||||
<app-provider-badge [provider]="channel.provider"></app-provider-badge>
|
||||
@@ -513,7 +515,7 @@
|
||||
|
||||
<!-- ==================== MODALE GROUPE ==================== -->
|
||||
<div *ngIf="showGroupModal()" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" (click)="closeGroupModal()">
|
||||
<div class="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl border border-slate-700 bg-slate-900 p-5 shadow-2xl" (click)="$event.stopPropagation()">
|
||||
<div class="max-h-[min(90dvh,100%)] w-full max-w-lg overflow-y-auto rounded-xl border border-slate-700 bg-slate-900 p-5 shadow-2xl" (click)="$event.stopPropagation()">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-2xl transition-colors"
|
||||
@@ -562,7 +564,7 @@
|
||||
class="w-full rounded-md border border-slate-700 bg-slate-800 py-2 pl-9 pr-3 text-sm text-slate-100 placeholder:text-slate-500 focus:border-red-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div *ngIf="filteredIcons().length > 0; else noGroupIcon" class="mt-2 grid max-h-48 grid-cols-8 gap-1 overflow-y-auto rounded-lg border border-slate-800 bg-slate-900/60 p-2 sm:grid-cols-10">
|
||||
<div *ngIf="filteredIcons().length > 0; else noGroupIcon" class="mt-2 grid max-h-48 grid-cols-6 gap-1 overflow-y-auto rounded-lg border border-slate-800 bg-slate-900/60 p-2 sm:grid-cols-8 lg:grid-cols-10">
|
||||
<button
|
||||
*ngFor="let icon of filteredIcons()"
|
||||
type="button"
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
<div class="fixed inset-0 z-[9999] bg-black/60" (click)="close.emit()">
|
||||
<div class="fixed inset-0 grid place-items-center">
|
||||
<div class="fixed inset-0 grid place-items-center p-4" [style.paddingBottom]="'max(1rem, var(--safe-bottom))'">
|
||||
<div #dialog
|
||||
class="w-[90%] max-w-lg bg-slate-900 rounded-xl shadow-2xl border border-slate-700"
|
||||
class="dialog-scroll flex w-full max-w-lg flex-col overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="providersTitle"
|
||||
(click)="$event.stopPropagation()"
|
||||
tabindex="0"
|
||||
(keydown)="onDialogKeydown($event)">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 id="providersTitle" class="text-slate-100 font-semibold">Providers</h2>
|
||||
<button type="button" (click)="close.emit()" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" aria-label="Fermer (Esc)">Esc</button>
|
||||
</div>
|
||||
<div class="p-4 space-y-3">
|
||||
<input type="text" [ngModel]="filter()" (ngModelChange)="filter.set($event)" placeholder="filter providers…" aria-label="Filtrer les fournisseurs" class="w-full px-3 py-2 rounded bg-slate-800 text-slate-100 placeholder-slate-400 border border-slate-700"/>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="inline-flex items-center gap-2 text-slate-200">
|
||||
<input type="checkbox" [checked]="allMode()" (change)="toggleAllMode()"/>
|
||||
<span>All</span>
|
||||
<div class="sticky top-0 z-10 shrink-0 border-b border-slate-700 bg-slate-900 p-4 flex items-center justify-between gap-3">
|
||||
<h2 id="providersTitle" class="text-slate-100 font-semibold">Providers</h2>
|
||||
<button type="button" (click)="close.emit()" class="chip px-3 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" aria-label="Fermer (Esc)">Esc</button>
|
||||
</div>
|
||||
<div class="p-4 space-y-3">
|
||||
<input type="text" [ngModel]="filter()" (ngModelChange)="filter.set($event)" placeholder="filter providers…" aria-label="Filtrer les fournisseurs" class="w-full min-w-0 px-3 py-2 rounded bg-slate-800 text-slate-100 placeholder-slate-400 border border-slate-700"/>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="inline-flex items-center gap-2 text-slate-200 py-1">
|
||||
<input type="checkbox" [checked]="allMode()" (change)="toggleAllMode()"/>
|
||||
<span>All</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<label *ngFor="let p of providers()" class="flex items-center gap-2 px-3 py-3 rounded border border-slate-700 bg-slate-800 text-slate-100">
|
||||
<input type="checkbox" class="shrink-0" [checked]="isChecked(p.id)" (change)="toggle(p.id)"/>
|
||||
<span class="text-xs font-semibold shrink-0" [class]="p.colorClass">{{ p.shortLabel }}</span>
|
||||
<span class="min-w-0 truncate text-sm">{{ p.displayName }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<label class="mt-2 inline-flex items-center gap-2 text-slate-300 py-1">
|
||||
<input type="checkbox" [ngModel]="rememberDefault()" (ngModelChange)="rememberDefault.set($event)" />
|
||||
<span>Remember as default</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label *ngFor="let p of providers()" class="flex items-center gap-2 px-2 py-2 rounded border border-slate-700 bg-slate-800 text-slate-100">
|
||||
<input type="checkbox" [checked]="isChecked(p.id)" (change)="toggle(p.id)"/>
|
||||
<span class="text-xs font-semibold" [class]="p.colorClass">{{ p.shortLabel }}</span>
|
||||
<span class="text-sm">{{ p.displayName }}</span>
|
||||
</label>
|
||||
<div class="sticky bottom-0 z-10 shrink-0 border-t border-slate-700 bg-slate-900 p-4 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||
<button type="button" (click)="close.emit()" class="px-4 py-3 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">Cancel</button>
|
||||
<button type="button" (click)="onApply()" class="px-4 py-3 rounded bg-red-600 hover:bg-red-500 text-white">Apply filters</button>
|
||||
</div>
|
||||
<label class="mt-2 inline-flex items-center gap-2 text-slate-300">
|
||||
<input type="checkbox" [ngModel]="rememberDefault()" (ngModelChange)="rememberDefault.set($event)" />
|
||||
<span>Remember as default</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="p-4 border-t border-slate-700 flex items-center justify-end gap-2">
|
||||
<button type="button" (click)="close.emit()" class="px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">Cancel</button>
|
||||
<button type="button" (click)="onApply()" class="px-3 py-2 rounded bg-red-600 hover:bg-red-500 text-white">Apply filters</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,48 +1,62 @@
|
||||
<form class="w-full relative" novalidate (submit)="onSubmit($event)" role="search" aria-label="Recherche">
|
||||
<!-- Input -->
|
||||
<div class="flex items-center gap-2 rounded-lg ring-1 ring-slate-600/50 bg-slate-800/70 px-3 py-2">
|
||||
<!-- Provider chips -->
|
||||
<button type="button"
|
||||
(click)="toggleAll()"
|
||||
class="text-xs px-2 py-0.5 rounded border"
|
||||
[ngClass]="{
|
||||
'bg-slate-200 text-slate-800 border-slate-300': providerMode()==='all',
|
||||
'bg-slate-700 text-slate-100 border-slate-600': providerMode()!=='all'
|
||||
}"
|
||||
[attr.aria-pressed]="providerMode()==='all'"
|
||||
title="Tous les fournisseurs">
|
||||
All
|
||||
</button>
|
||||
<ng-container *ngFor="let c of chips; let i = index">
|
||||
<button type="button" (click)="toggleChip(c.id)" (keydown.space)="$event.preventDefault(); toggleChip(c.id)" (keydown.enter)="$event.preventDefault(); toggleChip(c.id)" tabindex="0" role="button"
|
||||
class="text-xs px-2 py-0.5 rounded border"
|
||||
<form class="w-full min-w-0 relative" novalidate (submit)="onSubmit($event)" role="search" aria-label="Recherche">
|
||||
<!-- Input row -->
|
||||
<div class="flex items-center gap-2 rounded-lg ring-1 ring-slate-600/50 bg-slate-800/70 px-2 py-1.5 sm:px-3 sm:py-2">
|
||||
<!-- Provider chips: only when there is genuinely room for them (>= lg).
|
||||
On smaller screens the "@" picker below is the equivalent entry point. -->
|
||||
<div class="hidden lg:flex items-center gap-1.5 shrink-0">
|
||||
<button type="button"
|
||||
(click)="toggleAll()"
|
||||
class="chip text-xs px-2.5 rounded border transition-colors"
|
||||
[ngClass]="{
|
||||
'bg-slate-700 text-slate-100 border-slate-600': !(selected().includes(c.id)),
|
||||
'bg-slate-200 text-slate-800 border-slate-300': selected().includes(c.id)
|
||||
'bg-slate-200 text-slate-800 border-slate-300': providerMode()==='all',
|
||||
'bg-slate-700 text-slate-100 border-slate-600': providerMode()!=='all'
|
||||
}"
|
||||
[attr.aria-pressed]="selected().includes(c.id)"
|
||||
[title]="'Inclure ' + c.label + ' (Alt+' + (i + 1) + ')'">
|
||||
<span [class]="c.colorClass">{{ c.label }}</span>
|
||||
[attr.aria-pressed]="providerMode()==='all'"
|
||||
title="Tous les fournisseurs">
|
||||
All
|
||||
</button>
|
||||
</ng-container>
|
||||
<ng-container *ngFor="let c of chips; let i = index">
|
||||
<button type="button" (click)="toggleChip(c.id)" (keydown.space)="$event.preventDefault(); toggleChip(c.id)" (keydown.enter)="$event.preventDefault(); toggleChip(c.id)" tabindex="0" role="button"
|
||||
class="chip text-xs px-2.5 rounded border transition-colors"
|
||||
[ngClass]="{
|
||||
'bg-slate-700 text-slate-100 border-slate-600': !(selected().includes(c.id)),
|
||||
'bg-slate-200 text-slate-800 border-slate-300': selected().includes(c.id)
|
||||
}"
|
||||
[attr.aria-pressed]="selected().includes(c.id)"
|
||||
[title]="'Inclure ' + c.label + ' (Alt+' + (i + 1) + ')'">
|
||||
<span [class]="c.colorClass">{{ c.label }}</span>
|
||||
</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<!-- Text input: combobox pattern for the inline '@' autocomplete -->
|
||||
<input #qInput type="search"
|
||||
class="flex-1 bg-transparent outline-none text-slate-100 placeholder-slate-400 px-2"
|
||||
class="min-w-0 flex-1 bg-transparent outline-none text-slate-100 placeholder-slate-400 px-1 sm:px-2 py-2"
|
||||
[placeholder]="placeholder"
|
||||
[ngModel]="query()" (ngModelChange)="queryUpdate($event)" (keydown)="handleKeydown($event)" (keydown.enter)="onSubmit($event)" (focus)="onSuggestInputFocus()" name="q"
|
||||
autocomplete="off" aria-label="Rechercher des vidéos"
|
||||
autocomplete="off" aria-label="Rechercher des vidéos" enterkeyhint="search"
|
||||
role="combobox" aria-expanded="false" aria-autocomplete="list"
|
||||
[attr.aria-expanded]="atOpen() || suggestionsOpen()"
|
||||
[attr.aria-controls]="atOpen() ? 'atOptionsList' : (suggestionsOpen() ? 'sgOptionsList' : null)"
|
||||
[attr.aria-activedescendant]="atOpen() && atOptions().length > 0 ? 'atOption-' + atIndex() : (suggestionsOpen() && suggestIndex() >= 0 ? 'sgOption-' + suggestIndex() : null)"/>
|
||||
|
||||
<!-- Actions -->
|
||||
<button type="button" (click)="openPicker()" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600" aria-label="Choisir des fournisseurs (@)">@</button>
|
||||
<button type="submit" class="ml-1 px-3 py-1 rounded bg-red-600 hover:bg-red-500 text-white"
|
||||
<!-- Provider picker trigger: shows the active selection as a summary so the
|
||||
user always knows where they are searching. -->
|
||||
<button type="button" (click)="openPicker()"
|
||||
class="chip shrink-0 text-xs font-medium px-2.5 rounded bg-slate-700 text-slate-100 hover:bg-slate-600"
|
||||
aria-haspopup="dialog"
|
||||
[attr.aria-label]="'Fournisseurs : ' + providerSummary()">
|
||||
<span class="max-w-[5.5rem] truncate lg:hidden">{{ providerSummary() }}</span>
|
||||
<span class="hidden lg:inline" aria-hidden="true">@</span>
|
||||
</button>
|
||||
|
||||
<button type="submit" class="chip ml-auto shrink-0 inline-flex items-center gap-1.5 rounded bg-red-600 px-3 text-sm text-white hover:bg-red-500"
|
||||
(click)="onSubmit($event)"
|
||||
[attr.title]="(query() || '').trim().length < 2 ? 'Tapez au moins 2 caractères' : null">
|
||||
Search
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 lg:hidden" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5Zm-6 0A4.5 4.5 0 1 1 14 9.5 4.49 4.49 0 0 1 9.5 14Z"/>
|
||||
</svg>
|
||||
<span class="hidden lg:inline">Search</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -55,16 +69,16 @@
|
||||
|
||||
<!-- Inline @autocomplete popover -->
|
||||
<div *ngIf="atOpen() && atOptions().length > 0"
|
||||
class="absolute z-50 mt-1 left-3 right-3 rounded-lg shadow-xl overflow-hidden"
|
||||
class="absolute z-50 mt-1 left-0 right-0 max-h-[60dvh] overflow-y-auto rounded-lg shadow-xl"
|
||||
style="top: 100%">
|
||||
<ul id="atOptionsList" role="listbox" aria-label="Fournisseurs"
|
||||
class="max-h-64 overflow-y-auto"
|
||||
class="max-h-[60dvh] overflow-y-auto"
|
||||
[ngClass]="{ 'bg-slate-900 border border-slate-700 text-slate-200': true }">
|
||||
<li *ngFor="let opt of atOptions(); let i = index" role="option"
|
||||
[attr.aria-selected]="i === atIndex()"
|
||||
[id]="'atOption-' + i">
|
||||
<button type="button" (click)="applyAtOption(opt.id)"
|
||||
class="w-full text-left px-3 py-2 text-sm"
|
||||
class="w-full text-left px-3 py-3 text-sm"
|
||||
[ngClass]="{
|
||||
'bg-slate-800': i === atIndex(),
|
||||
'hover:bg-slate-800': i !== atIndex()
|
||||
@@ -77,43 +91,39 @@
|
||||
|
||||
<!-- Step 15: query typeahead panel (hidden while the @ popover is open) -->
|
||||
<div *ngIf="suggestionsOpen() && !atOpen() && suggestFlat().length > 0"
|
||||
class="absolute z-40 mt-1 left-3 right-3 rounded-lg shadow-xl overflow-hidden bg-slate-900 border border-slate-700 text-slate-200"
|
||||
class="absolute z-40 mt-1 left-0 right-0 max-h-[60dvh] overflow-y-auto rounded-lg shadow-xl bg-slate-900 border border-slate-700 text-slate-200"
|
||||
style="top: 100%">
|
||||
<ul id="sgOptionsList" role="listbox" aria-label="Suggestions de recherche"
|
||||
class="max-h-72 overflow-y-auto py-1">
|
||||
class="max-h-[60dvh] overflow-y-auto py-1">
|
||||
<li *ngFor="let item of suggestFlat(); let i = index" role="option"
|
||||
[attr.aria-selected]="i === suggestIndex()"
|
||||
[id]="'sgOption-' + i">
|
||||
<button type="button" (click)="applySuggestion(item.text, true)"
|
||||
(mouseenter)="suggestIndex.set(i)"
|
||||
class="w-full flex items-center gap-2 text-left px-3 py-2 text-sm"
|
||||
[ngClass]="{
|
||||
'bg-slate-800': i === suggestIndex(),
|
||||
'hover:bg-slate-800': i !== suggestIndex()
|
||||
}">
|
||||
<span *ngIf="!item.provider" class="text-slate-500" title="Recherche récente">🕘</span>
|
||||
<span *ngIf="item.provider" class="text-[10px] font-bold px-1.5 py-0.5 rounded bg-slate-700 text-slate-300">{{ providerBadge(item.provider) }}</span>
|
||||
<span class="flex-1 truncate" [title]="item.text"><span>{{ suggestHighlight(item.text).pre }}</span><mark *ngIf="suggestHighlight(item.text).match" class="bg-transparent text-inherit font-semibold underline decoration-red-500">{{ suggestHighlight(item.text).match }}</mark><span>{{ suggestHighlight(item.text).post }}</span></span>
|
||||
class="w-full flex items-center gap-2 text-left px-3 py-3 text-sm">
|
||||
<span *ngIf="!item.provider" class="text-slate-500 shrink-0" title="Recherche récente">🕘</span>
|
||||
<span *ngIf="item.provider" class="shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded bg-slate-700 text-slate-300">{{ providerBadge(item.provider) }}</span>
|
||||
<span class="min-w-0 flex-1 truncate" [title]="item.text"><span>{{ suggestHighlight(item.text).pre }}</span><mark *ngIf="suggestHighlight(item.text).match" class="bg-transparent text-inherit font-semibold underline decoration-red-500">{{ suggestHighlight(item.text).match }}</mark><span>{{ suggestHighlight(item.text).post }}</span></span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div *ngIf="suggestLoading()" class="px-3 py-1 text-xs text-slate-500 border-t border-slate-800">Chargement…</div>
|
||||
<div *ngIf="suggestLoading()" class="px-3 py-2 text-xs text-slate-500 border-t border-slate-800">Chargement…</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Menu (Ctrl/Cmd+K) -->
|
||||
<div *ngIf="quickOpen()" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; background-color: rgba(0, 0, 0, 0.6);" role="presentation" (click)="closeQuickMenu()">
|
||||
<div #quickDialog style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -0%); width: 90%; max-width: 56rem; max-height: 85vh; border-radius: 0.75rem; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; background-color: #0f172a; border: 1px solid #334155; display: flex; flex-direction: column;" role="dialog" aria-modal="true" aria-labelledby="quickMenuTitle" (click)="$event.stopPropagation()" (keydown)="onQuickMenuKeydown($event)">
|
||||
<div class="p-3 border-b border-slate-700 flex items-center justify-between">
|
||||
<div *ngIf="quickOpen()" class="quick-menu-backdrop" role="presentation" (click)="closeQuickMenu()">
|
||||
<div #quickDialog class="quick-menu-dialog" role="dialog" aria-modal="true" aria-labelledby="quickMenuTitle" (click)="$event.stopPropagation()" (keydown)="onQuickMenuKeydown($event)">
|
||||
<div class="p-3 border-b border-slate-700 flex items-center justify-between gap-3">
|
||||
<h2 id="quickMenuTitle" class="text-slate-100 font-semibold">Quick Menu</h2>
|
||||
<button type="button" (click)="closeQuickMenu()" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" aria-label="Fermer le menu (Esc)">Esc</button>
|
||||
<button type="button" (click)="closeQuickMenu()" class="chip px-3 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" aria-label="Fermer le menu (Esc)">Esc</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-0 overflow-y-auto">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 overflow-y-auto min-h-0 flex-1">
|
||||
<!-- History column -->
|
||||
<div class="p-4 border-r border-slate-700">
|
||||
<div class="p-4 md:border-r md:border-slate-700">
|
||||
<div class="text-slate-300 text-sm mb-2">History</div>
|
||||
<ul class="space-y-1 max-h-80 overflow-y-auto">
|
||||
<ul class="space-y-1 max-h-64 overflow-y-auto">
|
||||
<li *ngFor="let it of recentSearches()">
|
||||
<button type="button" class="w-full text-left px-2 py-1 rounded hover:bg-slate-800 text-slate-200"
|
||||
<button type="button" class="w-full text-left px-3 py-3 rounded hover:bg-slate-800 text-slate-200 break-anywhere"
|
||||
(click)="queryUpdate(it.query || ''); closeQuickMenu()">{{ it.query }}</button>
|
||||
</li>
|
||||
<li *ngIf="(recentSearches()?.length || 0) === 0" class="text-slate-500 text-sm">No recent searches.</li>
|
||||
@@ -121,20 +131,19 @@
|
||||
</div>
|
||||
<!-- Providers column -->
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center justify-between gap-3 mb-2">
|
||||
<div class="text-slate-300 text-sm">Providers</div>
|
||||
<button type="button" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100"
|
||||
<button type="button" class="chip text-xs px-3 rounded bg-slate-700 hover:bg-slate-600 text-slate-100"
|
||||
(click)="quickToggleAll()">All</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<label *ngFor="let c of chips" class="flex items-center gap-2 px-2 py-2 rounded border border-slate-700 bg-slate-800 text-slate-100">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<label *ngFor="let c of chips" class="flex items-center gap-2 px-3 py-3 rounded border border-slate-700 bg-slate-800 text-slate-100">
|
||||
<input type="checkbox" [checked]="selected().includes(c.id)" (change)="quickToggle(c.id)"/>
|
||||
<span class="text-xs font-semibold" [class]="c.colorClass">{{ c.label }}</span>
|
||||
<span class="text-sm">{{ c.label }}</span>
|
||||
<span class="text-sm truncate" [class]="c.colorClass">{{ c.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4 text-right">
|
||||
<button type="button" class="px-3 py-2 rounded bg-red-600 hover:bg-red-500 text-white"
|
||||
<button type="button" class="w-full sm:w-auto px-4 py-3 rounded bg-red-600 hover:bg-red-500 text-white"
|
||||
(click)="applyQuickMenu({ selection: selected().length ? selected() : 'all', rememberDefault: false })">Apply filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,12 @@ export interface SuggestFlatItem {
|
||||
templateUrl: './search-box.component.html'
|
||||
})
|
||||
export class SearchBoxComponent {
|
||||
/**
|
||||
* Host element, so a parent can tell whether this instance is the visible one.
|
||||
* Optional: unit tests instantiate the component without a host element.
|
||||
*/
|
||||
readonly el = inject(ElementRef, { optional: true });
|
||||
|
||||
private search = inject(SearchService);
|
||||
private users = inject(UserService);
|
||||
private history = inject(HistoryService);
|
||||
@@ -130,11 +136,35 @@ export class SearchBoxComponent {
|
||||
|
||||
readonly chips = PROVIDERS.map(p => ({ id: p.id, label: p.shortLabel, colorClass: p.colorClass }));
|
||||
|
||||
/**
|
||||
* Focus the query input programmatically (Ctrl/Cmd+K, `/`, mobile search
|
||||
* sheet). Exposed so the parent header does not have to reach into the DOM.
|
||||
*/
|
||||
focusInput(): HTMLInputElement | undefined {
|
||||
try { return this.qInputRef?.nativeElement; } catch { return undefined; }
|
||||
}
|
||||
|
||||
// Derived state
|
||||
readonly providersForRequest = computed<ProviderId[] | 'all'>(() => {
|
||||
return this.providerMode() === 'all' ? 'all' : (this.selected().length ? this.selected() : 'all');
|
||||
});
|
||||
|
||||
/**
|
||||
* Short human label for the current provider selection. On phones the chip
|
||||
* row is hidden, so this label is the only feedback the user gets about
|
||||
* where the search will be routed.
|
||||
*/
|
||||
readonly providerSummary = computed<string>(() => {
|
||||
if (this.providerMode() === 'all') return 'All';
|
||||
const sel = this.selected();
|
||||
if (!sel.length) return 'All';
|
||||
if (sel.length === 1) {
|
||||
const found = PROVIDERS.find((p) => p.id === sel[0]);
|
||||
return found ? found.shortLabel : String(sel[0]).toUpperCase();
|
||||
}
|
||||
return `${sel.length} src`;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Flag to avoid clobbering user input after initial hydration from SearchService
|
||||
let hydratedFromService = false;
|
||||
|
||||
@@ -26,10 +26,10 @@
|
||||
}
|
||||
|
||||
@if (hasQuery() && availableTags().length > 0) {
|
||||
<div class="mb-4 flex items-center gap-2 overflow-x-auto pb-1">
|
||||
<div class="scroll-rail no-scrollbar mb-4 flex items-center gap-2 pb-1">
|
||||
@for (tag of availableTags(); track tag.key) {
|
||||
<button
|
||||
class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="{
|
||||
'bg-slate-200 text-slate-900 border-slate-200': filterTag() === tag.key,
|
||||
'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700': filterTag() !== tag.key
|
||||
@@ -43,35 +43,35 @@
|
||||
|
||||
@if (hasQuery()) {
|
||||
<!-- Pills de type façon directory Twitch : Directs / Vidéos / Clips / Chaînes -->
|
||||
<div class="flex items-center gap-2 overflow-x-auto pb-1" role="tablist" aria-label="Filtrer par type de contenu">
|
||||
<button class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
<div class="scroll-rail no-scrollbar flex items-center gap-2 pb-1" role="tablist" aria-label="Filtrer par type de contenu">
|
||||
<button class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'all' ? 'bg-slate-200 text-slate-900 border-slate-200' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'all'" (click)="setTypePill('all')">
|
||||
Tout ({{ typeCounts().all }})
|
||||
</button>
|
||||
<button *ngIf="typeCounts().live > 0 || typePill() === 'live'" class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
<button *ngIf="typeCounts().live > 0 || typePill() === 'live'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'live' ? 'bg-purple-600 text-white border-purple-500' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'live'" (click)="setTypePill('live')">
|
||||
Directs ({{ typeCounts().live }})
|
||||
</button>
|
||||
<button *ngIf="typeCounts().video > 0 || typePill() === 'video'" class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
<button *ngIf="typeCounts().video > 0 || typePill() === 'video'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'video' ? 'bg-slate-200 text-slate-900 border-slate-200' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'video'" (click)="setTypePill('video')">
|
||||
Vidéos ({{ typeCounts().video }})
|
||||
</button>
|
||||
<button *ngIf="typeCounts().clip > 0 || typePill() === 'short'" class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
<button *ngIf="typeCounts().clip > 0 || typePill() === 'short'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'short' ? 'bg-red-600 text-white border-red-500' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'short'" (click)="setTypePill('short')">
|
||||
Clips ({{ typeCounts().clip }})
|
||||
</button>
|
||||
<button *ngIf="typeCounts().channel > 0 || typePill() === 'channel'" class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
<button *ngIf="typeCounts().channel > 0 || typePill() === 'channel'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'channel' ? 'bg-slate-200 text-slate-900 border-slate-200' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'channel'" (click)="setTypePill('channel')">
|
||||
Chaînes ({{ typeCounts().channel }})
|
||||
</button>
|
||||
</div>
|
||||
<!-- Barre de filtrage (même UX que la page thème / fournisseur) -->
|
||||
<div class="bg-slate-800/50 p-4 rounded-xl border border-slate-700 my-4">
|
||||
<div class="rounded-xl border border-slate-700 bg-slate-800/50 p-4 my-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div>
|
||||
<label for="sortSelect" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.sort' | t }}</label>
|
||||
|
||||
@@ -31,21 +31,21 @@ import { forkJoin } from 'rxjs';
|
||||
<div class="fixed inset-0 z-40 bg-black/60 backdrop-blur-[2px]" (click)="close()"></div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="fixed inset-0 z-50 grid place-items-center" aria-modal="true" role="dialog" [attr.aria-labelledby]="'addToPlaylistTitle'">
|
||||
<div class="w-[min(92vw,720px)] max-h-[min(80vh,700px)] rounded-xl bg-slate-900 border border-slate-700 shadow-2xl focus:outline-none overflow-hidden" (click)="$event.stopPropagation()">
|
||||
<div class="fixed inset-0 z-50 grid place-items-center p-4" [style.paddingBottom]="'max(1rem, var(--safe-bottom))'" aria-modal="true" role="dialog" [attr.aria-labelledby]="'addToPlaylistTitle'">
|
||||
<div class="flex w-[min(92vw,720px)] max-h-[min(80dvh,700px)] flex-col overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-2xl focus:outline-none" (click)="$event.stopPropagation()">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-slate-700">
|
||||
<div class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-700 px-4 py-4 sm:px-5">
|
||||
<div id="addToPlaylistTitle" class="text-base font-semibold text-slate-100">Vos playlists</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="text-xs text-slate-400 hover:text-slate-200" (click)="reloadPlaylists()">Rafraîchir</button>
|
||||
<button class="h-8 w-8 grid place-items-center rounded-md text-slate-300 hover:text-white hover:bg-slate-700/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60" (click)="close()" aria-label="Fermer">
|
||||
<button type="button" class="chip shrink-0 rounded px-3 py-2 text-xs text-slate-400 hover:text-slate-200" (click)="reloadPlaylists()">Rafraîchir</button>
|
||||
<button type="button" class="app-icon-btn shrink-0 text-slate-300 hover:text-white hover:bg-slate-700/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60" (click)="close()" aria-label="Fermer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5"><path d="M6.225 4.811a1 1 0 011.414 0L12 9.172l4.361-4.361a1 1 0 011.415 1.415L13.415 10.586l4.361 4.361a1 1 0 01-1.415 1.415L12 12l-4.361 4.361a1 1 0 01-1.414-1.415l4.36-4.36-4.36-4.36a1 1 0 010-1.415z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-5 py-4 space-y-4">
|
||||
<div class="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4 sm:px-5">
|
||||
@if (loading()) {
|
||||
<div class="text-sm text-slate-400">Chargement...</div>
|
||||
} @else if (error()) {
|
||||
@@ -53,10 +53,10 @@ import { forkJoin } from 'rxjs';
|
||||
} @else if (playlists().length === 0) {
|
||||
<div class="text-sm text-slate-400">Aucune playlist. Créez-en une ci-dessous.</div>
|
||||
} @else {
|
||||
<div class="max-h-[42vh] overflow-auto divide-y divide-slate-800 rounded-md border border-slate-800">
|
||||
<div class="max-h-[min(42dvh,320px)] overflow-auto divide-y divide-slate-800 rounded-md border border-slate-800">
|
||||
@for (pl of playlists(); track pl.id) {
|
||||
<label class="flex items-center gap-4 px-4 py-3 hover:bg-slate-800/60 cursor-pointer">
|
||||
<input type="checkbox" class="accent-red-500 h-4 w-4" [checked]="isSelected(pl.id)" (change)="toggleSelect(pl.id, $event)" />
|
||||
<input type="checkbox" class="h-5 w-5 shrink-0 accent-red-500" [checked]="isSelected(pl.id)" (change)="toggleSelect(pl.id, $event)" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm text-slate-200">{{ pl.title }}</div>
|
||||
<div class="text-xs text-slate-400">{{ pl.itemsCount || 0 }} vidéo(s)</div>
|
||||
@@ -69,16 +69,16 @@ import { forkJoin } from 'rxjs';
|
||||
<!-- Create new playlist -->
|
||||
<div class="border-t border-slate-700 pt-4">
|
||||
<label class="block text-xs text-slate-400 mb-2">Nouvelle playlist</label>
|
||||
<div class="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end">
|
||||
<div class="flex-1">
|
||||
<input type="text" class="w-full bg-slate-950 text-slate-100 border border-slate-700 rounded px-3 py-2 text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-500/60"
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div class="min-w-0 flex-1">
|
||||
<input type="text" class="w-full min-w-0 rounded border border-slate-700 bg-slate-950 px-3 py-3 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-500/60 sm:py-2"
|
||||
[value]="newTitle()" (input)="onTitleChange($event)" placeholder="Titre de la playlist" />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-xs text-slate-300">
|
||||
<input type="checkbox" class="accent-red-500" [checked]="newPrivate()" (change)="onPrivateChange($event)" />
|
||||
<label class="flex items-center gap-2 py-1 text-xs text-slate-300">
|
||||
<input type="checkbox" class="h-5 w-5 accent-red-500" [checked]="newPrivate()" (change)="onPrivateChange($event)" />
|
||||
Privée
|
||||
</label>
|
||||
<button class="px-4 py-2 rounded-md bg-red-600 hover:bg-red-500 text-white text-sm font-semibold disabled:opacity-60 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60"
|
||||
<button type="button" class="chip rounded-md bg-red-600 px-4 py-3 text-sm font-semibold text-white hover:bg-red-500 disabled:cursor-not-allowed disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 sm:py-2"
|
||||
[disabled]="!newTitle().trim() || busyAdd()"
|
||||
(click)="onQuickCreate()">Créer + Ajouter</button>
|
||||
</div>
|
||||
@@ -86,11 +86,11 @@ import { forkJoin } from 'rxjs';
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-5 py-4 border-t border-slate-700 bg-slate-900/60 flex items-center justify-between">
|
||||
<div class="flex shrink-0 flex-col gap-3 border-t border-slate-700 bg-slate-900/60 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
<div class="text-xs text-slate-400">{{ selectedCount() }} sélectionnée(s)</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="px-4 py-2 rounded-md bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400/60" (click)="close()">Annuler</button>
|
||||
<button class="px-4 py-2 rounded-md bg-red-600 hover:bg-red-500 text-white text-sm font-semibold disabled:opacity-60 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60"
|
||||
<div class="flex w-full items-center gap-3 sm:w-auto">
|
||||
<button type="button" class="chip flex-1 rounded-md bg-slate-700 px-4 py-3 text-sm text-slate-100 hover:bg-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400/60 sm:flex-none sm:py-2" (click)="close()">Annuler</button>
|
||||
<button type="button" class="chip flex-1 rounded-md bg-red-600 px-4 py-3 text-sm font-semibold text-white hover:bg-red-500 disabled:cursor-not-allowed disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 sm:flex-none sm:py-2"
|
||||
[disabled]="busyAdd() || selectedCount() === 0"
|
||||
(click)="onAddSelected()">Ajouter</button>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { AuthService } from '../../../../services/auth.service';
|
||||
[class.text-slate-400]="!isLiked()"
|
||||
[class.hover\:text-red-500]="!isLiked()"
|
||||
[disabled]="isLoading()"
|
||||
class="transition-colors duration-200 p-1 rounded-full hover:bg-slate-700/50 flex items-center justify-center"
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full transition-colors duration-200 hover:bg-slate-700/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60"
|
||||
[attr.aria-label]="isLiked() ? 'Retirer des vidéos aimées' : 'Ajouter aux vidéos aimées'"
|
||||
[attr.aria-pressed]="isLiked()"
|
||||
>
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
<div class="w-full flex flex-col items-center gap-3 p-2 sm:p-4">
|
||||
<div class="flex w-full flex-col items-center gap-3 p-2 sm:p-4">
|
||||
<!-- Sélecteur de fournisseur : la page n'est plus bloquée sur un seul provider -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-2" role="tablist" aria-label="Fournisseur des Shorts">
|
||||
<div class="scroll-rail no-scrollbar flex max-w-full items-center justify-start gap-2 sm:justify-center" role="tablist" aria-label="Fournisseur des Shorts">
|
||||
<button *ngFor="let p of providersList()"
|
||||
(click)="selectProvider(p.id)"
|
||||
[attr.aria-pressed]="provider() === p.id"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
|
||||
[class]="provider() === p.id ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
{{ p.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="h-[calc(100vh-8rem)] w-full flex items-center justify-center"
|
||||
<!-- Player area: height is derived from dvh *and* vw so a 9:16 frame never
|
||||
overflows either a portrait phone or a landscape one. -->
|
||||
<div class="flex w-full min-h-0 items-center justify-center"
|
||||
(wheel)="onWheel($event)" (touchstart)="onTouchStart($event)" (touchend)="onTouchEnd($event)">
|
||||
<!-- Center column -->
|
||||
<div class="relative">
|
||||
<div class="relative flex w-full min-w-0 items-center justify-center">
|
||||
<!-- Video frame (9:16) -->
|
||||
<div class="rounded-xl overflow-hidden shadow-2xl ring-1 ring-slate-700/50 bg-black/90"
|
||||
style="height: calc(100vh - 10rem); max-height: calc(100vh - 10rem); aspect-ratio: 9 / 16; width: auto; max-width: 92vw;">
|
||||
<div class="shorts-frame rounded-xl overflow-hidden shadow-2xl ring-1 ring-slate-700/50 bg-black/90">
|
||||
@if (embedUrl(); as url) {
|
||||
<iframe [src]="url" [title]="current()?.title || 'Short video'" class="w-full h-full" frameborder="0"
|
||||
<iframe [src]="url" [title]="current()?.title || 'Short video'" class="w-full h-full" frameborder="0" playsinline
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen></iframe>
|
||||
} @else if (loading()) {
|
||||
@@ -29,54 +30,54 @@
|
||||
} @else if (error()) {
|
||||
<div class="w-full h-full flex flex-col gap-3 items-center justify-center text-slate-300 p-6 text-center">
|
||||
<span>{{ error() }}</span>
|
||||
<button (click)="retry()" class="px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-white text-sm">Réessayer</button>
|
||||
<button (click)="retry()" class="chip px-4 py-3 rounded bg-slate-700 hover:bg-slate-600 text-white text-sm">Réessayer</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Bottom overlay: meta -->
|
||||
@if (current(); as v) {
|
||||
<div class="absolute left-0 right-0 bottom-0 p-3 sm:p-4 bg-gradient-to-t from-black/60 to-transparent text-white">
|
||||
<div class="absolute left-0 right-0 bottom-0 p-3 sm:p-4 bg-gradient-to-t from-black/70 to-transparent text-white">
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<img [src]="v.uploaderAvatar" [alt]="v.uploaderName" class="h-8 w-8 rounded-full object-cover">
|
||||
<img [src]="v.uploaderAvatar" [alt]="v.uploaderName" class="h-8 w-8 shrink-0 rounded-full object-cover">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold line-clamp-1">{{ v.title }}</div>
|
||||
<div class="opacity-80 line-clamp-1">@{{ v.uploaderName }}</div>
|
||||
</div>
|
||||
<a *ngIf="watchLink()" [routerLink]="watchLink()" [queryParams]="watchQueryParams()"
|
||||
class="shrink-0 px-2 py-1 rounded bg-white/15 hover:bg-white/25 text-xs">Ouvrir</a>
|
||||
class="chip shrink-0 px-3 py-2 rounded bg-white/20 hover:bg-white/30 text-xs">Ouvrir</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Navigation buttons -->
|
||||
@if (busyMore()) {
|
||||
<div class="absolute top-2 left-1/2 -translate-x-1/2 px-2 py-1 rounded-full bg-black/60 text-white text-xs" role="status">Chargement…</div>
|
||||
<div class="absolute top-2 left-1/2 -translate-x-1/2 px-3 py-2 rounded-full bg-black/70 text-white text-xs" role="status">Chargement…</div>
|
||||
}
|
||||
<button (click)="prev()" [disabled]="!canPrev()"
|
||||
class="absolute -right-14 top-6 hidden md:flex items-center justify-center h-10 w-10 rounded-full bg-slate-800/70 hover:bg-slate-700 text-white ring-1 ring-slate-700/60 disabled:opacity-40"
|
||||
aria-label="Previous short">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M7 14l5-5 5 5H7z"/></svg>
|
||||
</button>
|
||||
<button (click)="next()" [disabled]="!canNext()"
|
||||
class="absolute -right-14 top-20 hidden md:flex items-center justify-center h-10 w-10 rounded-full bg-slate-800/70 hover:bg-slate-700 text-white ring-1 ring-slate-700/60 disabled:opacity-40"
|
||||
aria-label="Next short">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M7 10l5 5 5-5H7z"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Position indicator -->
|
||||
@if (items().length > 0) {
|
||||
<div class="absolute -left-14 top-6 hidden md:flex items-center justify-center px-2 py-1 rounded-full bg-slate-800/70 text-white text-xs ring-1 ring-slate-700/60">
|
||||
{{ index() + 1 }} / {{ items().length }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Mobile next/prev (bottom) -->
|
||||
<div class="flex md:hidden items-center justify-between mt-3">
|
||||
<button (click)="prev()" [disabled]="!canPrev()" class="px-3 py-2 rounded bg-slate-800/70 text-white disabled:opacity-40">Prev</button>
|
||||
<span *ngIf="items().length > 0" class="text-slate-400 text-sm">{{ index() + 1 }} / {{ items().length }}</span>
|
||||
<button (click)="next()" [disabled]="!canNext()" class="px-3 py-2 rounded bg-slate-800/70 text-white disabled:opacity-40">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation. Position indicator is always visible (it is the only progress
|
||||
feedback); the prev/next arrows are inset inside the page instead of
|
||||
parked outside the frame at `-right-14`, which used to overflow. -->
|
||||
<div class="flex w-full max-w-lg items-center justify-between gap-3">
|
||||
<button type="button" (click)="prev()" [disabled]="!canPrev()"
|
||||
class="chip shrink-0 rounded-lg bg-slate-800/80 px-4 py-3 text-white disabled:opacity-40"
|
||||
aria-label="Short précédent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 14l5-5 5 5H7z"/></svg>
|
||||
<span class="ml-1">Préc.</span>
|
||||
</button>
|
||||
@if (items().length > 0) {
|
||||
<span class="shrink-0 text-sm tabular-nums text-slate-400" role="status" aria-live="polite">
|
||||
{{ index() + 1 }} / {{ items().length }}
|
||||
</span>
|
||||
} @else {
|
||||
<span class="shrink-0"></span>
|
||||
}
|
||||
<button type="button" (click)="next()" [disabled]="!canNext()"
|
||||
class="chip shrink-0 rounded-lg bg-slate-800/80 px-4 py-3 text-white disabled:opacity-40"
|
||||
aria-label="Short suivant">
|
||||
<span class="mr-1">Suiv.</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 10l5 5 5-5H7z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<nav tabindex="0" class="sidebar-scroll h-full w-full bg-slate-900 md:bg-transparent text-slate-200 p-3 md:p-0">
|
||||
<nav tabindex="0" aria-label="Navigation" class="sidebar-scroll h-full w-full bg-slate-900 md:bg-transparent text-slate-200 p-3 md:p-0">
|
||||
<div class="space-y-6">
|
||||
<!-- Brand (small only) -->
|
||||
<div class="md:hidden flex items-center gap-2 px-3 py-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-red-500" viewBox="0 0 24 24" fill="currentColor"><path d="M10,15.6l5.3-3.3L10,9V15.6z M21.8,8.1c-0.2-0.8-0.9-1.4-1.7-1.7C18.2,6,12,6,12,6s-6.2,0-8.1,0.5 c-0.8,0.2-1.4,0.9-1.7,1.7C2,9.9,2,12,2,12s0,2.1,0.5,3.9c0.2,0.8,0.9,1.4,1.7,1.7C6.2,18,12,18,12,18s6.2,0,8.1-0.5 c0.8-0.2,1.4-0.9,1.7-1.7c0.5-1.8,0.5-3.9,0.5-3.9S22.2,9.9,21.8,8.1z"/></svg>
|
||||
<span class="text-xl font-bold">NewTube</span>
|
||||
<!-- Brand + close (drawer only) -->
|
||||
<div class="md:hidden flex items-center gap-2 px-1 py-1">
|
||||
<a routerLink="/" class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 shrink-0 text-red-500" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M10,15.6l5.3-3.3L10,9V15.6z M21.8,8.1c-0.2-0.8-0.9-1.4-1.7-1.7C18.2,6,12,6,12,6s-6.2,0-8.1,0.5 c-0.8,0.2-1.4,0.9-1.7,1.7C2,9.9,2,12,2,12s0,2.1,0.5,3.9c0.2,0.8,0.9,1.4,1.7,1.7C6.2,18,12,18,12,18s6.2,0,8.1-0.5 c0.8-0.2,1.4-0.9,1.7-1.7c0.5-1.8,0.5-3.9,0.5-3.9S22.2,9.9,21.8,8.1z"/></svg>
|
||||
<span class="truncate text-lg font-bold">NewTube</span>
|
||||
</a>
|
||||
@if (inDrawer) {
|
||||
<button type="button" (click)="closeDrawer()" class="app-icon-btn shrink-0" [attr.aria-label]="'common.closeMenu' | t">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6.4 5 5 6.4 10.6 12 5 17.6 6.4 19 12 13.4 17.6 19 19 17.6 13.4 12 19 6.4 17.6 5 12 10.6 6.4 5Z"/></svg>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Main : toujours visible, hors accordeon -->
|
||||
@@ -12,19 +19,19 @@
|
||||
<li>
|
||||
<a routerLink="/t/trending" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.home' | t) : null">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M3 10.5L12 3l9 7.5V21a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1v-10.5z"/></svg>
|
||||
<span [class.hidden]="collapsed">{{ 'nav.home' | t }}</span>
|
||||
<span [class.hidden]="collapsed" class="truncate">{{ 'nav.home' | t }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a routerLink="/shorts" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.shorts' | t) : null">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M10 2l8 4v9l-8 4-8-4V6l8-4zm0 2.2L4 6.8v6.4l6 2.6 6-2.6V6.8l-6-2.6z"/></svg>
|
||||
<span [class.hidden]="collapsed">{{ 'nav.shorts' | t }}</span>
|
||||
<span [class.hidden]="collapsed" class="truncate">{{ 'nav.shorts' | t }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a routerLink="/library/playlists" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.playlists' | t) : null">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M4 6h12v2H4V6zm0 4h12v2H4v-2zm0 4h8v2H4v-2zm14-6h2v8h-2v-8z"/></svg>
|
||||
<span [class.hidden]="collapsed">{{ 'nav.playlists' | t }}</span>
|
||||
<span [class.hidden]="collapsed" class="truncate">{{ 'nav.playlists' | t }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -42,31 +49,31 @@
|
||||
<li>
|
||||
<a routerLink="/library/subscriptions" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.subscriptions' | t) : null">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zm0 2v12h16V6H4zm3 3h10v2H7V9zm0 4h7v2H7v-2z"/></svg>
|
||||
<span [class.hidden]="collapsed">{{ 'nav.subscriptions' | t }}</span>
|
||||
<span [class.hidden]="collapsed" class="truncate">{{ 'nav.subscriptions' | t }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a routerLink="/account/history" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.history' | t) : null">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M12 8a1 1 0 0 1 1 1v3.38l2.32 1.34a1 1 0 1 1-1 1.74l-2.82-1.63A1 1 0 0 1 11 13V9a1 1 0 0 1 1-1zm0-6a10 10 0 1 0 10 10 10.011 10.011 0 0 0-10-10z"/></svg>
|
||||
<span [class.hidden]="collapsed">{{ 'nav.history' | t }}</span>
|
||||
<span [class.hidden]="collapsed" class="truncate">{{ 'nav.history' | t }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a routerLink="/library/liked" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.liked' | t) : null">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M12 21s-6.716-4.248-9.193-6.725A6 6 0 0 1 12 5.414 6 6 0 0 1 21.193 14.275C18.716 16.752 12 21 12 21z"/></svg>
|
||||
<span [class.hidden]="collapsed">{{ 'nav.liked' | t }}</span>
|
||||
<span [class.hidden]="collapsed" class="truncate">{{ 'nav.liked' | t }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a routerLink="/library/downloads" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.downloads' | t) : null">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.17l3.59-3.58L17 11l-5 5-5-5 1.41-1.41L12 13.17V3h0zm-7 15h14v2H5v-2z"/></svg>
|
||||
<span [class.hidden]="collapsed">{{ 'nav.downloads' | t }}</span>
|
||||
<span [class.hidden]="collapsed" class="truncate">{{ 'nav.downloads' | t }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a routerLink="/library/import" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.import' | t) : null">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M12 4v12m0 0l-4-4m4 4l4-4"/></svg>
|
||||
<span [class.hidden]="collapsed">{{ 'nav.import' | t }}</span>
|
||||
<span [class.hidden]="collapsed" class="truncate">{{ 'nav.import' | t }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -141,7 +148,7 @@
|
||||
</svg>
|
||||
</ng-container>
|
||||
</span>
|
||||
<span [class.hidden]="collapsed">{{ p.label }}</span>
|
||||
<span [class.hidden]="collapsed" class="truncate">{{ p.label }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ng-container>
|
||||
@@ -157,7 +164,7 @@
|
||||
<polygon points="10,8 16,12 10,16" fill="white"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span [class.hidden]="collapsed">{{ p.label }}</span>
|
||||
<span [class.hidden]="collapsed" class="truncate">{{ p.label }}</span>
|
||||
</a>
|
||||
<!-- Sub-list of instances (visible when not collapsed) -->
|
||||
<ul class="ml-8 mt-1 space-y-1" [class.hidden]="collapsed">
|
||||
@@ -169,7 +176,7 @@
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor"/>
|
||||
<polygon points="10,8 16,12 10,16" fill="white"/>
|
||||
</svg>
|
||||
<span class="text-xs" [ngClass]="{ 'text-sky-400': inst === activePeerTubeInstance(), 'text-slate-300': inst !== activePeerTubeInstance() }">{{ inst }}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-xs" [title]="inst" [ngClass]="{ 'text-sky-400': inst === activePeerTubeInstance(), 'text-slate-300': inst !== activePeerTubeInstance() }">{{ inst }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -194,7 +201,7 @@
|
||||
<li *ngFor="let t of displayedThemes()">
|
||||
<a [routerLink]="['/t', t.slug]" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? (t.label) : null">
|
||||
<span class="h-6 w-6 flex items-center justify-center text-lg">{{ t.emoji }}</span>
|
||||
<span [class.hidden]="collapsed">{{ t.label }}</span>
|
||||
<span [class.hidden]="collapsed" class="truncate">{{ t.label }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -216,7 +223,7 @@
|
||||
<li>
|
||||
<a routerLink="/info/utilisation" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.usage' | t) : null">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2zm1 15h-2v-6h2zm0-8h-2V7h2z"/></svg>
|
||||
<span [class.hidden]="collapsed">{{ 'nav.usage' | t }}</span>
|
||||
<span [class.hidden]="collapsed" class="truncate">{{ 'nav.usage' | t }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, inject, Input, signal } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, inject, Input, signal, Output, EventEmitter } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { InstanceService } from '../../services/instance.service';
|
||||
@@ -126,4 +126,16 @@ export class SidebarComponent {
|
||||
}
|
||||
|
||||
@Input() collapsed = false;
|
||||
|
||||
/**
|
||||
* True when rendered inside the mobile drawer: adds a close affordance and
|
||||
* lets the nav route change signal the shell so the drawer can dismiss.
|
||||
*/
|
||||
@Input() inDrawer = false;
|
||||
|
||||
@Output() drawerClose = new EventEmitter<void>();
|
||||
|
||||
closeDrawer() {
|
||||
this.drawerClose.emit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
<div class="container mx-auto p-4 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3 text-slate-200">
|
||||
<a [routerLink]="['/t', theme()]" class="px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<!-- Stacks below `sm`: title + provider picker side by side cannot fit a phone. -->
|
||||
<!-- Wraps at `lg`: between md and lg the desktop sidebar leaves too little
|
||||
room for the title and the provider picker to share a row. -->
|
||||
<div class="mb-6 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-3 text-slate-200">
|
||||
<a [routerLink]="['/t', theme()]" aria-label="Retour aux thèmes" class="chip shrink-0 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 transition-colors hover:bg-slate-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold">
|
||||
<span class="text-4xl mr-2">{{ themes.bySlug(theme())?.emoji }}</span>
|
||||
{{ themes.bySlug(theme())?.label }}
|
||||
<h1 class="min-w-0 text-2xl font-bold sm:text-3xl">
|
||||
<span class="mr-2 text-3xl sm:text-4xl">{{ themes.bySlug(theme())?.emoji }}</span>
|
||||
<span class="break-anywhere">{{ themes.bySlug(theme())?.label }}</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm font-medium text-slate-400">{{ 'themes.changeProvider' | t }}</label>
|
||||
<div class="relative">
|
||||
<select [ngModel]="provider()" (ngModelChange)="changeProvider($event)" class="appearance-none bg-slate-800 text-white rounded-md px-3 py-2 pr-8 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all">
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<label for="providerSel" class="text-sm font-medium text-slate-400">{{ 'themes.changeProvider' | t }}</label>
|
||||
<div class="relative min-w-0 flex-1 sm:flex-none">
|
||||
<select id="providerSel" [ngModel]="provider()" (ngModelChange)="changeProvider($event)" class="w-full appearance-none rounded-md border border-slate-700 bg-slate-800 px-3 py-2 pr-9 text-white transition-all focus:outline-none focus:ring-2 focus:ring-blue-500 sm:w-auto">
|
||||
<option *ngFor="let p of providersList()" [value]="p.id">{{ p.label }}</option>
|
||||
</select>
|
||||
<svg class="w-5 h-5 absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<svg class="pointer-events-none absolute right-2.5 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -25,19 +28,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Filtre rapide Vidéos / Shorts (masqué sur Twitch : onglets à sections ci-dessous) -->
|
||||
<div *ngIf="provider() !== 'twitch'" class="flex items-center gap-2 mb-4" role="tablist" aria-label="Filtrer vidéos ou shorts">
|
||||
<div *ngIf="provider() !== 'twitch'" class="scroll-rail no-scrollbar mb-4 flex items-center gap-2" role="tablist" aria-label="Filtrer vidéos ou shorts">
|
||||
<button (click)="setContentFilter('all')" [attr.aria-pressed]="contentFilter() === 'all'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
|
||||
[class]="contentFilter() === 'all' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Tout
|
||||
</button>
|
||||
<button (click)="setContentFilter('videos')" [attr.aria-pressed]="contentFilter() === 'videos'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
|
||||
[class]="contentFilter() === 'videos' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Vidéos
|
||||
</button>
|
||||
<button (click)="setContentFilter('shorts')" [attr.aria-pressed]="contentFilter() === 'shorts'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
|
||||
[class]="contentFilter() === 'shorts' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Shorts<span *ngIf="allVideos().length"> ({{ shortsCount() }})</span>
|
||||
</button>
|
||||
@@ -52,29 +55,29 @@
|
||||
<div class="font-semibold text-slate-100">{{ twitchGame()?.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 overflow-x-auto pb-1" role="tablist" aria-label="Sections Twitch">
|
||||
<div class="scroll-rail no-scrollbar flex items-center gap-2 pb-1" role="tablist" aria-label="Sections Twitch">
|
||||
<button (click)="setTwitchTab('all')" [attr.aria-pressed]="twitchTab() === 'all'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
[class]="twitchTab() === 'all' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Tout
|
||||
</button>
|
||||
<button (click)="setTwitchTab('live')" [attr.aria-pressed]="twitchTab() === 'live'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
[class]="twitchTab() === 'live' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
En direct<span *ngIf="twitchLives().length"> ({{ twitchLives().length }})</span>
|
||||
</button>
|
||||
<button (click)="setTwitchTab('video')" [attr.aria-pressed]="twitchTab() === 'video'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
[class]="twitchTab() === 'video' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Vidéos<span *ngIf="twitchVods().length"> ({{ twitchVods().length }})</span>
|
||||
</button>
|
||||
<button (click)="setTwitchTab('clip')" [attr.aria-pressed]="twitchTab() === 'clip'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
[class]="twitchTab() === 'clip' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Clips<span *ngIf="twitchClips().length"> ({{ twitchClips().length }})</span>
|
||||
</button>
|
||||
<button (click)="setTwitchTab('channel')" [attr.aria-pressed]="twitchTab() === 'channel'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
[class]="twitchTab() === 'channel' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Chaînes<span *ngIf="twitchChannels().length"> ({{ twitchChannels().length }})</span>
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="container mx-auto p-4 sm:p-6">
|
||||
<h2 class="text-3xl font-bold mb-4 text-slate-100 border-l-4 border-red-500 pl-4 flex items-center gap-2">
|
||||
<h2 class="mb-4 flex items-center gap-2 border-l-4 border-red-500 pl-4 text-2xl font-bold text-slate-100 sm:text-3xl">
|
||||
<span>{{ themeLabel() }}</span>
|
||||
</h2>
|
||||
|
||||
|
||||
@@ -1,53 +1,62 @@
|
||||
@if (collapsed()) {
|
||||
<!-- Barre repliée : fine, rouvrable à tout moment -->
|
||||
<div class="sticky top-16 left-0 right-0 z-40 flex w-full items-center gap-2 border-b border-slate-700/60 bg-slate-800/60 px-3 py-1 backdrop-blur-sm">
|
||||
<span class="text-xs font-medium text-slate-400">{{ 'themes.title' | t : 'Thèmes' }}</span>
|
||||
@if (activeThemeLabel(); as label) {
|
||||
<span class="rounded-full border border-red-500/60 bg-slate-100/10 px-2 py-0.5 text-xs text-white">{{ label }}</span>
|
||||
}
|
||||
<div class="sticky z-40 flex w-full items-center gap-2 border-b border-slate-700/60 bg-slate-800/60 px-3 py-1 backdrop-blur-sm"
|
||||
[style.top]="'var(--header-offset)'"
|
||||
[style.paddingLeft]="'max(0.75rem, var(--safe-left))'"
|
||||
[style.paddingRight]="'max(0.75rem, var(--safe-right))'">
|
||||
<span class="shrink-0 text-xs font-medium text-slate-400">{{ 'themes.title' | t : 'Thèmes' }}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-xs text-slate-500">
|
||||
@if (activeThemeLabel(); as label) {
|
||||
<span class="inline-block max-w-full truncate rounded-full border border-red-500/60 bg-slate-100/10 px-2 py-0.5 text-slate-200">{{ label }}</span>
|
||||
}
|
||||
</span>
|
||||
<button type="button" (click)="toggleCollapsed()" [attr.aria-expanded]="false" aria-controls="themes-bar"
|
||||
[title]="'themes.show' | t : 'Afficher la bande des thèmes'"
|
||||
class="ml-auto inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-semibold text-slate-300 transition hover:bg-slate-700 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500">
|
||||
class="chip ml-auto inline-flex shrink-0 items-center gap-1 rounded-full px-3 text-xs font-semibold text-slate-300 transition hover:bg-slate-700 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500">
|
||||
<span>{{ 'themes.show' | t : 'Afficher' }}</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<nav id="themes-bar" class="sticky top-16 left-0 right-0 z-40 w-full relative"
|
||||
<nav id="themes-bar" class="sticky z-40 w-full relative"
|
||||
[style.top]="'var(--header-offset)'"
|
||||
[ngClass]="{
|
||||
'bg-slate-800 border-b border-slate-700': isDarkTheme(),
|
||||
'bg-slate-900 border-b border-slate-800': isBlackTheme(),
|
||||
'bg-slate-700 border-b border-slate-600': isBlueTheme()
|
||||
'bg-slate-700 border-b border-slate-600': isBlueTheme(),
|
||||
'bg-slate-50 border-b border-slate-200': isLightTheme()
|
||||
}">
|
||||
<!-- Left scroll button -->
|
||||
<button type="button" (click)="scrollBy(-320)" aria-label="Scroll left"
|
||||
<!-- Left scroll button: pointer devices only. Touch users swipe. -->
|
||||
<button type="button" (click)="scrollBy(-320)" aria-label="Scroll left" tabindex="-1"
|
||||
class="hidden md:flex items-center justify-center absolute left-2 top-1/2 -translate-y-1/2 h-9 w-9 rounded-full shadow z-10"
|
||||
[ngClass]="{
|
||||
'bg-white/90 border border-slate-300 text-slate-700 hover:bg-slate-100': isLightTheme(),
|
||||
'bg-slate-800/90 border border-slate-700 text-slate-200 hover:bg-slate-700': isDarkTheme() || isBlackTheme() || isBlueTheme()
|
||||
'bg-slate-800/90 border border-slate-700 text-slate-200 hover:bg-slate-700': !isLightTheme()
|
||||
}">
|
||||
«
|
||||
</button>
|
||||
|
||||
<!-- Right scroll button -->
|
||||
<button type="button" (click)="scrollBy(320)" aria-label="Scroll right"
|
||||
<!-- Right scroll button: pointer devices only. Touch users swipe. -->
|
||||
<button type="button" (click)="scrollBy(320)" aria-label="Scroll right" tabindex="-1"
|
||||
class="hidden md:flex items-center justify-center absolute right-2 top-1/2 -translate-y-1/2 h-9 w-9 rounded-full shadow z-10"
|
||||
[ngClass]="{
|
||||
'bg-white/90 border border-slate-300 text-slate-700 hover:bg-slate-100': isLightTheme(),
|
||||
'bg-slate-800/90 border border-slate-700 text-slate-200 hover:bg-slate-700': isDarkTheme() || isBlackTheme() || isBlueTheme()
|
||||
'bg-slate-800/90 border border-slate-700 text-slate-200 hover:bg-slate-700': !isLightTheme()
|
||||
}">
|
||||
»
|
||||
</button>
|
||||
|
||||
<!-- Replier la bande : action disponible uniquement dans le header -->
|
||||
<div class="w-full overflow-x-auto no-scrollbar" #scroll (wheel)="onWheel($event)">
|
||||
<ul role="tablist" aria-label="Themes" class="flex gap-2 px-12 py-2 min-w-max items-center">
|
||||
<!-- Replier la bande : action disponible uniquement dans le header.
|
||||
On phones the rail is edge-to-edge with a swipe affordance instead of
|
||||
the reserved arrow gutters. -->
|
||||
<div class="scroll-rail no-scrollbar md:overflow-x-auto" #scroll (wheel)="onWheel($event)">
|
||||
<ul role="tablist" aria-label="Themes" class="flex min-w-max items-center gap-2 px-3 py-2 md:px-12">
|
||||
<li *ngFor="let t of displayedThemes(); let i = index" class="shrink-0">
|
||||
<button
|
||||
#pill
|
||||
role="tab"
|
||||
type="button"
|
||||
class="px-3 py-1.5 rounded-full text-sm font-medium border transition-colors whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
class="chip px-3 py-1.5 rounded-full text-sm font-medium border transition-colors whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
[class.bg-slate-100/10]="activeSlug() === t.slug"
|
||||
[class.text-white]="activeSlug() === t.slug"
|
||||
[class.border-red-500]="activeSlug() === t.slug"
|
||||
@@ -63,5 +72,14 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Scroll affordance: the rail hides its scrollbar, so fade the trailing
|
||||
edge to signal that more themes exist to the right. -->
|
||||
<span class="pointer-events-none absolute inset-y-0 right-0 w-10"
|
||||
aria-hidden="true"
|
||||
[ngClass]="{
|
||||
'bg-gradient-to-l from-slate-800 to-transparent': !isLightTheme(),
|
||||
'bg-gradient-to-l from-slate-50 to-transparent': isLightTheme()
|
||||
}"></span>
|
||||
</nav>
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<button (click)="toggleCinema()" [attr.aria-pressed]="cinemaMode()"
|
||||
[title]="cinemaMode() ? 'Quitter le mode cinéma (Échap)' : 'Mode cinéma : élargir la vidéo (T)'"
|
||||
[attr.aria-label]="cinemaMode() ? 'Quitter le mode cinéma' : 'Activer le mode cinéma'"
|
||||
class="absolute right-3 top-12 z-10 inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-semibold shadow-lg transition-opacity focus-visible:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
class="chip max-md:opacity-100 absolute right-3 top-12 z-10 inline-flex items-center gap-1.5 rounded-full px-3 py-2 text-xs font-semibold shadow-lg transition-opacity focus-visible:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
[class.opacity-0]="!cinemaMode()" [class.group-hover:opacity-100]="!cinemaMode()" [class.group-focus-within:opacity-100]="!cinemaMode()"
|
||||
[class.bg-red-600]="cinemaMode()" [class.text-white]="cinemaMode()"
|
||||
[class.bg-black/70]="!cinemaMode()" [class.text-slate-200]="!cinemaMode()" [class.hover:bg-black/90]="!cinemaMode()">
|
||||
@@ -24,7 +24,7 @@
|
||||
</button>
|
||||
<div class="aspect-video">
|
||||
@if (embedUrl(); as src) {
|
||||
<iframe #embedFrame class="h-full w-full" [src]="src" frameborder="0" (load)="onEmbedFrameLoad()"
|
||||
<iframe #embedFrame class="h-full w-full" [src]="src" frameborder="0" playsinline (load)="onEmbedFrameLoad()"
|
||||
referrerpolicy="origin"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen title="Lecteur vidéo"></iframe>
|
||||
@@ -131,7 +131,7 @@
|
||||
</span>
|
||||
} @else if (canSubscribe() && !isLoggedIn()) {
|
||||
<a routerLink="/auth/login" title="Connectez-vous pour vous abonner (raccourci : S une fois connecté)"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-slate-600 bg-slate-800/70 px-3 py-1.5 text-sm font-semibold text-slate-200 transition hover:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-slate-600 bg-slate-800/70 px-3 py-2 text-sm font-semibold text-slate-200 transition hover:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
S'abonner
|
||||
</a>
|
||||
}
|
||||
@@ -145,14 +145,14 @@
|
||||
|
||||
<!-- Barre d'actions -->
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2 border-t border-slate-700/60 pt-3" role="toolbar" aria-label="Actions vidéo">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-slate-700/70 px-3 py-1.5 text-sm text-slate-200" title="Mentions J'aime">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-slate-700/70 px-3 py-2 text-sm text-slate-200" title="Mentions J'aime">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.787l.09.044a2 2 0 002.253-.334l.09-.092l3.535-3.536a2 2 0 00.586-1.414V5a2 2 0 00-2-2H9a2 2 0 00-2 2v5.333z" /></svg>
|
||||
<span class="tabular-nums">{{ formatNumber(v.likes) }}</span>
|
||||
</span>
|
||||
@if (isLoggedIn()) {
|
||||
<button (click)="toggleLike()" [disabled]="likeBusy()"
|
||||
[attr.aria-pressed]="liked()" [title]="liked() ? 'Ne plus aimer' : 'J\'aime'"
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-3 py-2 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
[class.bg-red-600]="liked()" [class.text-white]="liked()"
|
||||
[class.bg-slate-700]="!liked()" [class.text-slate-200]="!liked()" [class.hover:bg-slate-600]="!liked()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" [attr.fill]="liked() ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
@@ -168,7 +168,7 @@
|
||||
<app-add-to-playlist [provider]="provider()" [videoId]="v.videoId" [title]="v.title" [thumbnail]="v.thumbnail"></app-add-to-playlist>
|
||||
}
|
||||
<button (click)="copyLink()" title="Copier le lien de la vidéo"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-slate-700 px-3 py-1.5 text-sm font-semibold text-slate-200 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-slate-700 px-3 py-2 text-sm font-semibold text-slate-200 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M16 1a3 3 0 013 3v10a3 3 0 01-3 3H6a3 3 0 01-3-3V4a3 3 0 013-3h10zm0 2H6a1 1 0 00-1 1v10a1 1 0 001 1h10a1 1 0 001-1V4a1 1 0 00-1-1z"/>
|
||||
<path d="M18 7h2a3 3 0 013 3v10a3 3 0 01-3 3H10a3 3 0 01-3-3v-2h2v2a1 1 0 001 1h10a1 1 0 001-1V10a1 1 0 00-1-1h-2V7z"/>
|
||||
@@ -177,7 +177,7 @@
|
||||
</button>
|
||||
<button (click)="toggleTranscript()" [attr.aria-expanded]="transcriptOpen()" aria-controls="transcript-panel"
|
||||
[title]="transcriptOpen() ? 'Masquer la transcription' : 'Afficher la transcription'"
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-3 py-2 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
[class.bg-red-600]="transcriptOpen()" [class.text-white]="transcriptOpen()"
|
||||
[class.bg-slate-700]="!transcriptOpen()" [class.text-slate-200]="!transcriptOpen()" [class.hover:bg-slate-600]="!transcriptOpen()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
@@ -187,7 +187,7 @@
|
||||
</button>
|
||||
<button (click)="toggleCinema()" [attr.aria-pressed]="cinemaMode()"
|
||||
[title]="cinemaMode() ? 'Quitter le mode cinéma (Échap)' : 'Mode cinéma : la vidéo prend la largeur de la page (T)'"
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-3 py-2 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
[class.bg-red-600]="cinemaMode()" [class.text-white]="cinemaMode()"
|
||||
[class.bg-slate-700]="!cinemaMode()" [class.text-slate-200]="!cinemaMode()" [class.hover:bg-slate-600]="!cinemaMode()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
@@ -197,7 +197,7 @@
|
||||
</button>
|
||||
@if (isLoggedIn()) {
|
||||
<button (click)="openDownloadPanel()" title="Télécharger la vidéo"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-slate-700 px-3 py-1.5 text-sm font-semibold text-slate-200 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-slate-700 px-3 py-2 text-sm font-semibold text-slate-200 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M10 3a1 1 0 011 1v7l2.293-2.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 11V4a1 1 0 011-1z" />
|
||||
<path d="M4 17a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1z" />
|
||||
@@ -206,7 +206,7 @@
|
||||
</button>
|
||||
}
|
||||
<a [href]="openOnProviderUrl() || '#'" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-slate-700 px-3 py-1.5 text-sm font-semibold text-slate-200 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-slate-700 px-3 py-2 text-sm font-semibold text-slate-200 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
[class.pointer-events-none]="!openOnProviderUrl()" [class.opacity-60]="!openOnProviderUrl()"
|
||||
title="Ouvrir sur le site du fournisseur">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
@@ -237,12 +237,12 @@
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h2 class="font-semibold text-slate-100">Téléchargement</h2>
|
||||
<button (click)="closeDownloadPanel()" title="Fermer le panneau de téléchargement"
|
||||
class="rounded-full p-1.5 text-slate-400 transition hover:bg-slate-700 hover:text-slate-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400" aria-label="Fermer">
|
||||
class="app-icon-btn shrink-0 text-slate-400 hover:bg-slate-700 hover:text-slate-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400" aria-label="Fermer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap items-end gap-4">
|
||||
<div class="min-w-[240px] flex-1">
|
||||
<div class="w-full min-w-0 flex-1">
|
||||
<label for="fmtSel" class="mb-1 block text-sm text-slate-400">Format / Qualité</label>
|
||||
@if (formatsLoading()) {
|
||||
<div class="text-sm text-slate-400">Chargement des formats…</div>
|
||||
@@ -265,13 +265,14 @@
|
||||
(change)="audioOnly.set(audioChk.checked)" />
|
||||
<label for="audioOnly" class="text-sm text-slate-300">Audio uniquement</label>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<div class="ml-auto w-full sm:w-auto">
|
||||
|
||||
@if (!jobId()) {
|
||||
<button (click)="startDownload()" class="rounded-lg bg-red-600 px-4 py-2 font-semibold text-white transition hover:bg-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
<button (click)="startDownload()" class="chip w-full rounded-lg bg-red-600 px-4 py-3 font-semibold text-white transition hover:bg-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400 sm:w-auto">
|
||||
Démarrer
|
||||
</button>
|
||||
} @else {
|
||||
<button (click)="cancelDownload()" class="rounded-lg bg-slate-700 px-4 py-2 text-white transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
<button (click)="cancelDownload()" class="chip w-full rounded-lg bg-slate-700 px-4 py-3 text-white transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400 sm:w-auto">
|
||||
Annuler
|
||||
</button>
|
||||
}
|
||||
@@ -291,7 +292,7 @@
|
||||
</div>
|
||||
@if (jobFileReady()) {
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<button (click)="downloadFile()" class="rounded-lg bg-green-600 px-4 py-2 font-semibold text-white transition hover:bg-green-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-green-400">
|
||||
<button (click)="downloadFile()" class="chip w-full rounded-lg bg-green-600 px-4 py-3 font-semibold text-white transition hover:bg-green-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-green-400 sm:w-auto">
|
||||
Télécharger le fichier
|
||||
</button>
|
||||
@if (jobFileSize(); as size) {
|
||||
@@ -303,7 +304,7 @@
|
||||
@if (downloadError() && !formatsLoading()) {
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3" role="alert">
|
||||
<span class="text-sm text-red-300">{{ downloadError() }}</span>
|
||||
<button (click)="retryFormats()" class="rounded-full bg-slate-700 px-3 py-1 text-xs font-semibold text-slate-200 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
<button (click)="retryFormats()" class="chip rounded-full bg-slate-700 px-3 py-2 text-xs font-semibold text-slate-200 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
@@ -314,7 +315,7 @@
|
||||
<!-- Commentaires -->
|
||||
<section class="mt-8" aria-label="Commentaires">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 class="text-xl font-bold text-white">{{ v.comments.length }} commentaire{{ v.comments.length > 1 ? 's' : '' }}</h2>
|
||||
<h2 class="text-lg font-bold text-white sm:text-xl">{{ v.comments.length }} commentaire{{ v.comments.length > 1 ? 's' : '' }}</h2>
|
||||
@if (v.comments.length > 0) {
|
||||
<div class="flex items-center gap-2">
|
||||
<button (click)="onSummarizeComments()"
|
||||
@@ -396,7 +397,7 @@
|
||||
<div class="animate-pulse space-y-4" aria-hidden="true">
|
||||
@for (item of [1,2,3,4,5]; track item) {
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="h-24 w-40 shrink-0 rounded-xl bg-slate-800"></div>
|
||||
<div class="h-20 w-32 shrink-0 rounded-xl bg-slate-800 sm:h-24 sm:w-40"></div>
|
||||
<div class="flex-1 space-y-2 pt-1">
|
||||
<div class="h-4 rounded bg-slate-800"></div>
|
||||
<div class="h-4 w-2/3 rounded bg-slate-800"></div>
|
||||
@@ -428,13 +429,13 @@
|
||||
@if (transcriptAvailable() && transcriptLines().length > 0) {
|
||||
<button (click)="toggleTranscriptAutoScroll()" [attr.aria-pressed]="transcriptAutoScroll()"
|
||||
[title]="transcriptAutoScroll() ? 'Suivi activé : la transcription défile et surligne le passage en cours de lecture' : 'Activer le suivi : défilement auto et surlignage du passage lu'"
|
||||
class="rounded-full px-2.5 py-1 text-xs font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
class="chip rounded-full px-3 py-2 text-xs font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
[class.bg-red-600]="transcriptAutoScroll()" [class.text-white]="transcriptAutoScroll()"
|
||||
[class.bg-slate-700]="!transcriptAutoScroll()" [class.text-slate-300]="!transcriptAutoScroll()">
|
||||
Suivi {{ transcriptAutoScroll() ? 'activé' : 'coupé' }}
|
||||
</button>
|
||||
<button (click)="copyTranscript()" title="Copier la transcription filtrée"
|
||||
class="rounded-full bg-slate-700 px-2.5 py-1 text-xs font-semibold text-slate-200 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
class="chip rounded-full bg-slate-700 px-3 py-2 text-xs font-semibold text-slate-200 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
{{ transcriptCopied() ? 'Copié !' : 'Copier' }}
|
||||
</button>
|
||||
}
|
||||
@@ -448,7 +449,7 @@
|
||||
class="w-full rounded-xl border border-slate-700 bg-slate-900 py-2 pl-9 pr-9 text-sm text-slate-200 placeholder:text-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400" />
|
||||
@if (transcriptSearch()) {
|
||||
<button (click)="clearTranscriptSearch()" title="Effacer la recherche" aria-label="Effacer la recherche"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-400 transition hover:bg-slate-700 hover:text-slate-200">
|
||||
class="absolute right-1 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-700 hover:text-slate-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
|
||||
</button>
|
||||
}
|
||||
@@ -474,7 +475,7 @@
|
||||
[class.bg-red-500/15]="i === transcriptActiveIndex()"
|
||||
[class.ring-1]="i === transcriptActiveIndex()" [class.ring-red-500/40]="i === transcriptActiveIndex()">
|
||||
<button (click)="seekTranscript(line.t)" [title]="'Aller à ' + formatTimestamp(line.t)"
|
||||
class="shrink-0 rounded px-1 font-mono text-xs tabular-nums text-red-300 transition hover:bg-red-500/20 hover:text-red-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
class="chip shrink-0 rounded px-2 font-mono text-xs tabular-nums text-red-300 transition hover:bg-red-500/20 hover:text-red-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
{{ formatTimestamp(line.t) }}
|
||||
</button>
|
||||
<span class="min-w-0 flex-1 leading-relaxed" [class.text-white]="i === transcriptActiveIndex()" [class.text-slate-300]="i !== transcriptActiveIndex()">{{ line.text }}</span>
|
||||
@@ -497,7 +498,7 @@
|
||||
<p class="text-sm text-slate-400">{{ transcriptError() || 'Aucun sous-titre disponible pour cette vidéo.' }}</p>
|
||||
@if (transcriptRetryable()) {
|
||||
<button (click)="retryTranscript()"
|
||||
class="mt-3 inline-flex items-center gap-2 rounded-full bg-slate-700 px-3 py-1.5 text-sm font-semibold text-slate-100 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
class="mt-3 inline-flex items-center gap-2 rounded-full bg-slate-700 px-3 py-2 text-sm font-semibold text-slate-100 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01-.61-1.276z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
@@ -515,8 +516,8 @@
|
||||
@for (related of v.relatedStreams; track related.videoId) {
|
||||
<div class="group rounded-xl p-2 transition hover:bg-slate-800/60 focus-within:bg-slate-800/60">
|
||||
<a [routerLink]="['/watch', related.videoId]" [queryParams]="watchQueryParams(related)" [state]="{ video: related }" class="flex items-start gap-3 rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||
<div class="relative w-40 shrink-0">
|
||||
<img [src]="related.thumbnail" [alt]="related.title" class="h-24 w-full rounded-xl object-cover" loading="lazy">
|
||||
<div class="relative w-32 shrink-0 sm:w-40">
|
||||
<img [src]="related.thumbnail" [alt]="related.title" class="h-20 w-full rounded-xl object-cover sm:h-24" loading="lazy">
|
||||
<div class="absolute right-1.5 top-1.5">
|
||||
<app-provider-badge [provider]="related.provider"></app-provider-badge>
|
||||
</div>
|
||||
@@ -563,7 +564,7 @@
|
||||
<div class="space-y-4">
|
||||
@for (item of [1,2,3,4,5]; track item) {
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="h-24 w-40 shrink-0 rounded-xl bg-slate-800"></div>
|
||||
<div class="h-20 w-32 shrink-0 rounded-xl bg-slate-800 sm:h-24 sm:w-40"></div>
|
||||
<div class="flex-1 space-y-2 pt-1">
|
||||
<div class="h-4 rounded bg-slate-800"></div>
|
||||
<div class="h-4 w-2/3 rounded bg-slate-800"></div>
|
||||
|
||||
@@ -23,6 +23,12 @@ const EN: Record<string, string> = {
|
||||
'menu.preferences': 'Preferences',
|
||||
'menu.appearance': 'Appearance',
|
||||
'menu.logout': 'Logout',
|
||||
'menu.more': 'More options',
|
||||
'menu.search': 'Search',
|
||||
'menu.close': 'Close',
|
||||
'menu.navigation': 'Navigation',
|
||||
'menu.theme': 'Theme',
|
||||
'theme.system': 'System',
|
||||
'theme.black': 'Black',
|
||||
'theme.dark': 'Dark',
|
||||
'theme.blue': 'Blue',
|
||||
@@ -89,6 +95,8 @@ const EN: Record<string, string> = {
|
||||
'empty.noItems': 'No items.',
|
||||
'common.more': 'More',
|
||||
'common.less': 'Less',
|
||||
'common.skipToContent': 'Skip to main content',
|
||||
'common.closeMenu': 'Close menu',
|
||||
// Filters
|
||||
'filter.sort': 'Sort by',
|
||||
'filter.sort.recent': 'Most recent',
|
||||
@@ -169,6 +177,12 @@ const FR: Record<string, string> = {
|
||||
'menu.preferences': 'Préférences',
|
||||
'menu.appearance': 'Apparence',
|
||||
'menu.logout': 'Déconnexion',
|
||||
'menu.more': 'Plus d\'options',
|
||||
'menu.search': 'Rechercher',
|
||||
'menu.close': 'Fermer',
|
||||
'menu.navigation': 'Navigation',
|
||||
'menu.theme': 'Thème',
|
||||
'theme.system': 'Système',
|
||||
'theme.black': 'Noir',
|
||||
'theme.dark': 'Sombre',
|
||||
'theme.blue': 'Bleu',
|
||||
@@ -235,6 +249,8 @@ const FR: Record<string, string> = {
|
||||
'empty.noItems': 'Aucun élément.',
|
||||
'common.more': 'Plus',
|
||||
'common.less': 'Moins',
|
||||
'common.skipToContent': 'Aller au contenu principal',
|
||||
'common.closeMenu': 'Fermer le menu',
|
||||
// Filters
|
||||
'filter.sort': 'Tri',
|
||||
'filter.sort.recent': 'Plus récents',
|
||||
|
||||
Reference in New Issue
Block a user