chore: update Angular cache and TypeScript build info files

This commit is contained in:
Bruno Charest 2025-09-22 14:45:16 -04:00
parent 4eb339eb22
commit d78afda4cd
47 changed files with 5480 additions and 3995 deletions

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@ -91,15 +91,17 @@ import {
provideNetlifyLoader, provideNetlifyLoader,
registerLocaleData, registerLocaleData,
setRootDomAdapter setRootDomAdapter
} from "./chunk-H4LQPAO2.js"; } from "./chunk-QTXEQHHS.js";
import { import {
XhrFactory, XhrFactory,
parseCookieValue parseCookieValue
} from "./chunk-OUSM42MY.js"; } from "./chunk-OUSM42MY.js";
import { import {
DOCUMENT,
IMAGE_CONFIG IMAGE_CONFIG
} from "./chunk-FVA7C6JK.js"; } from "./chunk-XYAQCRC2.js";
import {
DOCUMENT
} from "./chunk-GFLMLXUS.js";
import "./chunk-HWYXSU2G.js"; import "./chunk-HWYXSU2G.js";
import "./chunk-JRFR6BLO.js"; import "./chunk-JRFR6BLO.js";
import "./chunk-MARUHEWW.js"; import "./chunk-MARUHEWW.js";

View File

@ -38,9 +38,10 @@ import {
withNoXsrfProtection, withNoXsrfProtection,
withRequestsMadeViaParent, withRequestsMadeViaParent,
withXsrfConfiguration withXsrfConfiguration
} from "./chunk-5DRVFSXL.js"; } from "./chunk-GND3ZHQO.js";
import "./chunk-OUSM42MY.js"; import "./chunk-OUSM42MY.js";
import "./chunk-FVA7C6JK.js"; import "./chunk-XYAQCRC2.js";
import "./chunk-GFLMLXUS.js";
import "./chunk-HWYXSU2G.js"; import "./chunk-HWYXSU2G.js";
import "./chunk-JRFR6BLO.js"; import "./chunk-JRFR6BLO.js";
import "./chunk-MARUHEWW.js"; import "./chunk-MARUHEWW.js";

View File

@ -14,10 +14,8 @@ import {
Attribute, Attribute,
CLIENT_RENDER_MODE_FLAG, CLIENT_RENDER_MODE_FLAG,
COMPILER_OPTIONS, COMPILER_OPTIONS,
CONTAINER_HEADER_OFFSET,
CSP_NONCE, CSP_NONCE,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionScheduler,
ChangeDetectionSchedulerImpl, ChangeDetectionSchedulerImpl,
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
@ -37,23 +35,17 @@ import {
DEFER_BLOCK_CONFIG, DEFER_BLOCK_CONFIG,
DEFER_BLOCK_DEPENDENCY_INTERCEPTOR, DEFER_BLOCK_DEPENDENCY_INTERCEPTOR,
DEHYDRATED_BLOCK_REGISTRY, DEHYDRATED_BLOCK_REGISTRY,
DOCUMENT,
DebugElement, DebugElement,
DebugEventListener, DebugEventListener,
DebugNode, DebugNode,
DefaultIterableDiffer, DefaultIterableDiffer,
DeferBlockBehavior, DeferBlockBehavior,
DeferBlockState, DeferBlockState,
DestroyRef,
Directive, Directive,
ENABLE_ROOT_COMPONENT_BOOTSTRAP, ENABLE_ROOT_COMPONENT_BOOTSTRAP,
ENVIRONMENT_INITIALIZER,
EffectScheduler,
ElementRef, ElementRef,
ElementRegistry, ElementRegistry,
EmbeddedViewRef, EmbeddedViewRef,
EnvironmentInjector,
ErrorHandler,
EventEmitter, EventEmitter,
FactoryTarget, FactoryTarget,
Framework, Framework,
@ -65,16 +57,11 @@ import {
HydrationStatus, HydrationStatus,
IMAGE_CONFIG, IMAGE_CONFIG,
IMAGE_CONFIG_DEFAULTS, IMAGE_CONFIG_DEFAULTS,
INJECTOR$1,
INJECTOR_SCOPE,
INTERNAL_APPLICATION_ERROR_HANDLER,
IS_ENABLED_BLOCKING_INITIAL_NAVIGATION, IS_ENABLED_BLOCKING_INITIAL_NAVIGATION,
IS_HYDRATION_DOM_REUSE_ENABLED, IS_HYDRATION_DOM_REUSE_ENABLED,
IS_INCREMENTAL_HYDRATION_ENABLED, IS_INCREMENTAL_HYDRATION_ENABLED,
Inject, Inject,
Injectable, Injectable,
InjectionToken,
Injector,
Input, Input,
IterableDiffers, IterableDiffers,
JSACTION_BLOCK_ELEMENT_MAP, JSACTION_BLOCK_ELEMENT_MAP,
@ -86,13 +73,6 @@ import {
MAX_ANIMATION_TIMEOUT, MAX_ANIMATION_TIMEOUT,
MissingTranslationStrategy, MissingTranslationStrategy,
ModuleWithComponentFactories, ModuleWithComponentFactories,
NG_COMP_DEF,
NG_DIR_DEF,
NG_ELEMENT_ID,
NG_INJ_DEF,
NG_MOD_DEF,
NG_PIPE_DEF,
NG_PROV_DEF,
NOT_FOUND_CHECK_ONLY_ELEMENT_INJECTOR, NOT_FOUND_CHECK_ONLY_ELEMENT_INJECTOR,
NO_CHANGE, NO_CHANGE,
NO_ERRORS_SCHEMA, NO_ERRORS_SCHEMA,
@ -106,19 +86,15 @@ import {
NoopNgZone, NoopNgZone,
Optional, Optional,
Output, Output,
OutputEmitterRef,
PACKAGE_ROOT_URL, PACKAGE_ROOT_URL,
PERFORMANCE_MARK_PREFIX, PERFORMANCE_MARK_PREFIX,
PLATFORM_ID, PLATFORM_ID,
PLATFORM_INITIALIZER, PLATFORM_INITIALIZER,
PROVIDED_NG_ZONE, PROVIDED_NG_ZONE,
PendingTasks,
PendingTasksInternal,
Pipe, Pipe,
PlatformRef, PlatformRef,
Query, Query,
QueryList, QueryList,
R3Injector,
REQUEST, REQUEST,
REQUEST_CONTEXT, REQUEST_CONTEXT,
RESPONSE_INIT, RESPONSE_INIT,
@ -126,9 +102,6 @@ import {
Renderer2, Renderer2,
RendererFactory2, RendererFactory2,
RendererStyleFlags2, RendererStyleFlags2,
ResourceImpl,
RuntimeError,
SIGNAL,
SSR_CONTENT_INTEGRITY_MARKER, SSR_CONTENT_INTEGRITY_MARKER,
Sanitizer, Sanitizer,
SecurityContext, SecurityContext,
@ -155,9 +128,6 @@ import {
ViewEncapsulation, ViewEncapsulation,
ViewRef, ViewRef,
ViewRef2, ViewRef2,
XSS_SECURITY_URL,
ZONELESS_ENABLED,
_global,
_sanitizeHtml, _sanitizeHtml,
_sanitizeUrl, _sanitizeUrl,
afterEveryRender, afterEveryRender,
@ -166,8 +136,6 @@ import {
allowSanitizationBypassAndThrow, allowSanitizationBypassAndThrow,
annotateForHydration, annotateForHydration,
asNativeElements, asNativeElements,
assertInInjectionContext,
assertNotInReactiveContext,
assertPlatform, assertPlatform,
booleanAttribute, booleanAttribute,
bypassSanitizationTrustHtml, bypassSanitizationTrustHtml,
@ -182,13 +150,10 @@ import {
compileNgModuleDefs, compileNgModuleDefs,
compileNgModuleFactory, compileNgModuleFactory,
compilePipe, compilePipe,
computed,
contentChild, contentChild,
contentChildren, contentChildren,
convertToBitFlags,
createComponent, createComponent,
createEnvironmentInjector, createEnvironmentInjector,
createInjector,
createNgModule, createNgModule,
createNgModuleRef, createNgModuleRef,
createOrReusePlatformInjector, createOrReusePlatformInjector,
@ -196,43 +161,32 @@ import {
createPlatformFactory, createPlatformFactory,
defaultIterableDiffers, defaultIterableDiffers,
defaultKeyValueDiffers, defaultKeyValueDiffers,
defineInjectable,
depsTracker, depsTracker,
destroyPlatform, destroyPlatform,
devModeEqual, devModeEqual,
disableProfiling, disableProfiling,
effect,
enableProdMode, enableProdMode,
enableProfiling, enableProfiling,
enableProfiling2, enableProfiling2,
encapsulateResourceError,
findLocaleData, findLocaleData,
flushModuleScopingQueueAsMuchAsPossible, flushModuleScopingQueueAsMuchAsPossible,
formatRuntimeError,
forwardRef,
generateStandaloneInDeclarationsError, generateStandaloneInDeclarationsError,
getAnimationElementRemovalRegistry,
getAsyncClassMetadataFn, getAsyncClassMetadataFn,
getClosestComponentName, getClosestComponentName,
getComponentDef,
getDebugNode, getDebugNode,
getDeferBlocks$1, getDeferBlocks$1,
getDirectives, getDirectives,
getDocument, getDocument,
getHostElement, getHostElement,
getInjectableDef,
getLContext, getLContext,
getLocaleCurrencyCode, getLocaleCurrencyCode,
getLocalePluralCase, getLocalePluralCase,
getModuleFactory, getModuleFactory,
getNgModuleById, getNgModuleById,
getOutputDestroyRef,
getPlatform, getPlatform,
getSanitizationBypassType, getSanitizationBypassType,
getTransferState, getTransferState,
importProvidersFrom,
inferTagNameFromDefinition, inferTagNameFromDefinition,
inject,
injectChangeDetectorRef, injectChangeDetectorRef,
input, input,
inputBinding, inputBinding,
@ -241,16 +195,10 @@ import {
isBoundToModule, isBoundToModule,
isComponentDefPendingResolution, isComponentDefPendingResolution,
isDevMode, isDevMode,
isEnvironmentProviders,
isInjectable,
isNgModule, isNgModule,
isPromise, isPromise,
isSignal,
isStandalone,
isSubscribable, isSubscribable,
isViewDirty, isViewDirty,
linkedSignal,
makeEnvironmentProviders,
makeStateKey, makeStateKey,
markForRefresh, markForRefresh,
mergeApplicationConfig, mergeApplicationConfig,
@ -263,9 +211,7 @@ import {
performanceMarkFeature, performanceMarkFeature,
platformCore, platformCore,
provideAppInitializer, provideAppInitializer,
provideBrowserGlobalErrorListeners,
provideCheckNoChangesConfig, provideCheckNoChangesConfig,
provideEnvironmentInitializer,
provideNgReflectAttributes, provideNgReflectAttributes,
providePlatformInitializer, providePlatformInitializer,
provideZoneChangeDetection, provideZoneChangeDetection,
@ -279,30 +225,19 @@ import {
resetCompiledComponents, resetCompiledComponents,
resetJitOptions, resetJitOptions,
resolveComponentResources, resolveComponentResources,
resolveForwardRef,
resource,
restoreComponentResolutionQueue, restoreComponentResolutionQueue,
runInInjectionContext,
setAllowDuplicateNgModuleIdsForTest, setAllowDuplicateNgModuleIdsForTest,
setAlternateWeakRefImpl,
setClassMetadata, setClassMetadata,
setClassMetadataAsync, setClassMetadataAsync,
setCurrentInjector,
setDocument, setDocument,
setInjectorProfilerContext,
setLocaleId, setLocaleId,
setTestabilityGetter, setTestabilityGetter,
signal,
startMeasuring, startMeasuring,
stopMeasuring, stopMeasuring,
store,
stringify,
transitiveScopesFor, transitiveScopesFor,
triggerResourceLoading, triggerResourceLoading,
truncateMiddle,
twoWayBinding, twoWayBinding,
unregisterAllLocaleData, unregisterAllLocaleData,
untracked,
unwrapSafeValue, unwrapSafeValue,
viewChild, viewChild,
viewChildren, viewChildren,
@ -317,7 +252,6 @@ import {
ɵsetClassDebugInfo, ɵsetClassDebugInfo,
ɵsetUnknownElementStrictMode, ɵsetUnknownElementStrictMode,
ɵsetUnknownPropertyStrictMode, ɵsetUnknownPropertyStrictMode,
ɵunwrapWritableSignal,
ɵɵAnimationsFeature, ɵɵAnimationsFeature,
ɵɵCopyDefinitionFeature, ɵɵCopyDefinitionFeature,
ɵɵExternalStylesFeature, ɵɵExternalStylesFeature,
@ -368,12 +302,9 @@ import {
ɵɵdeferWhen, ɵɵdeferWhen,
ɵɵdefineComponent, ɵɵdefineComponent,
ɵɵdefineDirective, ɵɵdefineDirective,
ɵɵdefineInjectable,
ɵɵdefineInjector,
ɵɵdefineNgModule, ɵɵdefineNgModule,
ɵɵdefinePipe, ɵɵdefinePipe,
ɵɵdirectiveInject, ɵɵdirectiveInject,
ɵɵdisableBindings,
ɵɵdomElement, ɵɵdomElement,
ɵɵdomElementContainer, ɵɵdomElementContainer,
ɵɵdomElementContainerEnd, ɵɵdomElementContainerEnd,
@ -389,7 +320,6 @@ import {
ɵɵelementContainerStart, ɵɵelementContainerStart,
ɵɵelementEnd, ɵɵelementEnd,
ɵɵelementStart, ɵɵelementStart,
ɵɵenableBindings,
ɵɵgetComponentDepsFactory, ɵɵgetComponentDepsFactory,
ɵɵgetCurrentView, ɵɵgetCurrentView,
ɵɵgetInheritedFactory, ɵɵgetInheritedFactory,
@ -401,7 +331,6 @@ import {
ɵɵi18nExp, ɵɵi18nExp,
ɵɵi18nPostprocess, ɵɵi18nPostprocess,
ɵɵi18nStart, ɵɵi18nStart,
ɵɵinject,
ɵɵinjectAttribute, ɵɵinjectAttribute,
ɵɵinterpolate, ɵɵinterpolate,
ɵɵinterpolate1, ɵɵinterpolate1,
@ -414,12 +343,8 @@ import {
ɵɵinterpolate8, ɵɵinterpolate8,
ɵɵinterpolateV, ɵɵinterpolateV,
ɵɵinvalidFactory, ɵɵinvalidFactory,
ɵɵinvalidFactoryDep,
ɵɵlistener, ɵɵlistener,
ɵɵloadQuery, ɵɵloadQuery,
ɵɵnamespaceHTML,
ɵɵnamespaceMathML,
ɵɵnamespaceSVG,
ɵɵnextContext, ɵɵnextContext,
ɵɵngDeclareClassMetadata, ɵɵngDeclareClassMetadata,
ɵɵngDeclareClassMetadataAsync, ɵɵngDeclareClassMetadataAsync,
@ -458,11 +383,9 @@ import {
ɵɵrepeaterTrackByIdentity, ɵɵrepeaterTrackByIdentity,
ɵɵrepeaterTrackByIndex, ɵɵrepeaterTrackByIndex,
ɵɵreplaceMetadata, ɵɵreplaceMetadata,
ɵɵresetView,
ɵɵresolveBody, ɵɵresolveBody,
ɵɵresolveDocument, ɵɵresolveDocument,
ɵɵresolveWindow, ɵɵresolveWindow,
ɵɵrestoreView,
ɵɵsanitizeHtml, ɵɵsanitizeHtml,
ɵɵsanitizeResourceUrl, ɵɵsanitizeResourceUrl,
ɵɵsanitizeScript, ɵɵsanitizeScript,
@ -497,7 +420,86 @@ import {
ɵɵvalidateIframeAttribute, ɵɵvalidateIframeAttribute,
ɵɵviewQuery, ɵɵviewQuery,
ɵɵviewQuerySignal ɵɵviewQuerySignal
} from "./chunk-FVA7C6JK.js"; } from "./chunk-XYAQCRC2.js";
import {
CONTAINER_HEADER_OFFSET,
ChangeDetectionScheduler,
DOCUMENT,
DestroyRef,
ENVIRONMENT_INITIALIZER,
EffectScheduler,
EnvironmentInjector,
ErrorHandler,
INJECTOR$1,
INJECTOR_SCOPE,
INTERNAL_APPLICATION_ERROR_HANDLER,
InjectionToken,
Injector,
NG_COMP_DEF,
NG_DIR_DEF,
NG_ELEMENT_ID,
NG_INJ_DEF,
NG_MOD_DEF,
NG_PIPE_DEF,
NG_PROV_DEF,
OutputEmitterRef,
PendingTasks,
PendingTasksInternal,
R3Injector,
ResourceImpl,
RuntimeError,
SIGNAL,
XSS_SECURITY_URL,
ZONELESS_ENABLED,
_global,
assertInInjectionContext,
assertNotInReactiveContext,
computed,
convertToBitFlags,
createInjector,
defineInjectable,
effect,
encapsulateResourceError,
formatRuntimeError,
forwardRef,
getAnimationElementRemovalRegistry,
getComponentDef,
getInjectableDef,
getOutputDestroyRef,
importProvidersFrom,
inject,
isEnvironmentProviders,
isInjectable,
isSignal,
isStandalone,
linkedSignal,
makeEnvironmentProviders,
provideBrowserGlobalErrorListeners,
provideEnvironmentInitializer,
resolveForwardRef,
resource,
runInInjectionContext,
setAlternateWeakRefImpl,
setCurrentInjector,
setInjectorProfilerContext,
signal,
store,
stringify,
truncateMiddle,
untracked,
ɵunwrapWritableSignal,
ɵɵdefineInjectable,
ɵɵdefineInjector,
ɵɵdisableBindings,
ɵɵenableBindings,
ɵɵinject,
ɵɵinvalidFactoryDep,
ɵɵnamespaceHTML,
ɵɵnamespaceMathML,
ɵɵnamespaceSVG,
ɵɵresetView,
ɵɵrestoreView
} from "./chunk-GFLMLXUS.js";
import "./chunk-HWYXSU2G.js"; import "./chunk-HWYXSU2G.js";
import "./chunk-JRFR6BLO.js"; import "./chunk-JRFR6BLO.js";
import "./chunk-MARUHEWW.js"; import "./chunk-MARUHEWW.js";

View File

@ -0,0 +1,253 @@
import {
DestroyRef,
Injector,
PendingTasks,
RuntimeError,
assertInInjectionContext,
assertNotInReactiveContext,
computed,
effect,
encapsulateResourceError,
getOutputDestroyRef,
inject,
resource,
signal,
untracked
} from "./chunk-GFLMLXUS.js";
import "./chunk-HWYXSU2G.js";
import "./chunk-JRFR6BLO.js";
import {
Observable,
ReplaySubject,
takeUntil
} from "./chunk-MARUHEWW.js";
import {
__spreadProps,
__spreadValues
} from "./chunk-GOMI4DH3.js";
// node_modules/@angular/core/fesm2022/rxjs-interop.mjs
function takeUntilDestroyed(destroyRef) {
if (!destroyRef) {
ngDevMode && assertInInjectionContext(takeUntilDestroyed);
destroyRef = inject(DestroyRef);
}
const destroyed$ = new Observable((subscriber) => {
if (destroyRef.destroyed) {
subscriber.next();
return;
}
const unregisterFn = destroyRef.onDestroy(subscriber.next.bind(subscriber));
return unregisterFn;
});
return (source) => {
return source.pipe(takeUntil(destroyed$));
};
}
var OutputFromObservableRef = class {
source;
destroyed = false;
destroyRef = inject(DestroyRef);
constructor(source) {
this.source = source;
this.destroyRef.onDestroy(() => {
this.destroyed = true;
});
}
subscribe(callbackFn) {
if (this.destroyed) {
throw new RuntimeError(953, ngDevMode && "Unexpected subscription to destroyed `OutputRef`. The owning directive/component is destroyed.");
}
const subscription = this.source.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: (value) => callbackFn(value)
});
return {
unsubscribe: () => subscription.unsubscribe()
};
}
};
function outputFromObservable(observable, opts) {
ngDevMode && assertInInjectionContext(outputFromObservable);
return new OutputFromObservableRef(observable);
}
function outputToObservable(ref) {
const destroyRef = getOutputDestroyRef(ref);
return new Observable((observer) => {
const unregisterOnDestroy = destroyRef?.onDestroy(() => observer.complete());
const subscription = ref.subscribe((v) => observer.next(v));
return () => {
subscription.unsubscribe();
unregisterOnDestroy?.();
};
});
}
function toObservable(source, options) {
if (ngDevMode && !options?.injector) {
assertInInjectionContext(toObservable);
}
const injector = options?.injector ?? inject(Injector);
const subject = new ReplaySubject(1);
const watcher = effect(() => {
let value;
try {
value = source();
} catch (err) {
untracked(() => subject.error(err));
return;
}
untracked(() => subject.next(value));
}, { injector, manualCleanup: true });
injector.get(DestroyRef).onDestroy(() => {
watcher.destroy();
subject.complete();
});
return subject.asObservable();
}
function toSignal(source, options) {
typeof ngDevMode !== "undefined" && ngDevMode && assertNotInReactiveContext(toSignal, "Invoking `toSignal` causes new subscriptions every time. Consider moving `toSignal` outside of the reactive context and read the signal value where needed.");
const requiresCleanup = !options?.manualCleanup;
if (ngDevMode && requiresCleanup && !options?.injector) {
assertInInjectionContext(toSignal);
}
const cleanupRef = requiresCleanup ? options?.injector?.get(DestroyRef) ?? inject(DestroyRef) : null;
const equal = makeToSignalEqual(options?.equal);
let state;
if (options?.requireSync) {
state = signal({
kind: 0
/* StateKind.NoValue */
}, { equal });
} else {
state = signal({ kind: 1, value: options?.initialValue }, { equal });
}
let destroyUnregisterFn;
const sub = source.subscribe({
next: (value) => state.set({ kind: 1, value }),
error: (error) => {
state.set({ kind: 2, error });
destroyUnregisterFn?.();
},
complete: () => {
destroyUnregisterFn?.();
}
// Completion of the Observable is meaningless to the signal. Signals don't have a concept of
// "complete".
});
if (options?.requireSync && state().kind === 0) {
throw new RuntimeError(601, (typeof ngDevMode === "undefined" || ngDevMode) && "`toSignal()` called with `requireSync` but `Observable` did not emit synchronously.");
}
destroyUnregisterFn = cleanupRef?.onDestroy(sub.unsubscribe.bind(sub));
return computed(() => {
const current = state();
switch (current.kind) {
case 1:
return current.value;
case 2:
throw current.error;
case 0:
throw new RuntimeError(601, (typeof ngDevMode === "undefined" || ngDevMode) && "`toSignal()` called with `requireSync` but `Observable` did not emit synchronously.");
}
}, { equal: options?.equal });
}
function makeToSignalEqual(userEquality = Object.is) {
return (a, b) => a.kind === 1 && b.kind === 1 && userEquality(a.value, b.value);
}
function pendingUntilEvent(injector) {
if (injector === void 0) {
ngDevMode && assertInInjectionContext(pendingUntilEvent);
injector = inject(Injector);
}
const taskService = injector.get(PendingTasks);
return (sourceObservable) => {
return new Observable((originalSubscriber) => {
const removeTask = taskService.add();
let cleanedUp = false;
function cleanupTask() {
if (cleanedUp) {
return;
}
removeTask();
cleanedUp = true;
}
const innerSubscription = sourceObservable.subscribe({
next: (v) => {
originalSubscriber.next(v);
cleanupTask();
},
complete: () => {
originalSubscriber.complete();
cleanupTask();
},
error: (e) => {
originalSubscriber.error(e);
cleanupTask();
}
});
innerSubscription.add(() => {
originalSubscriber.unsubscribe();
cleanupTask();
});
return innerSubscription;
});
};
}
function rxResource(opts) {
if (ngDevMode && !opts?.injector) {
assertInInjectionContext(rxResource);
}
return resource(__spreadProps(__spreadValues({}, opts), {
loader: void 0,
stream: (params) => {
let sub;
const onAbort = () => sub?.unsubscribe();
params.abortSignal.addEventListener("abort", onAbort);
const stream = signal({ value: void 0 });
let resolve;
const promise = new Promise((r) => resolve = r);
function send(value) {
stream.set(value);
resolve?.(stream);
resolve = void 0;
}
const streamFn = opts.stream ?? opts.loader;
if (streamFn === void 0) {
throw new RuntimeError(990, ngDevMode && `Must provide \`stream\` option.`);
}
sub = streamFn(params).subscribe({
next: (value) => send({ value }),
error: (error) => {
send({ error: encapsulateResourceError(error) });
params.abortSignal.removeEventListener("abort", onAbort);
},
complete: () => {
if (resolve) {
send({
error: new RuntimeError(991, ngDevMode && "Resource completed before producing a value")
});
}
params.abortSignal.removeEventListener("abort", onAbort);
}
});
return promise;
}
}));
}
export {
outputFromObservable,
outputToObservable,
pendingUntilEvent,
rxResource,
takeUntilDestroyed,
toObservable,
toSignal
};
/*! Bundled license information:
@angular/core/fesm2022/rxjs-interop.mjs:
(**
* @license Angular v20.2.4
* (c) 2010-2025 Google LLC. https://angular.io/
* License: MIT
*)
*/
//# sourceMappingURL=@angular_core_rxjs-interop.js.map

File diff suppressed because one or more lines are too long

View File

@ -1,51 +1,53 @@
import { import {
getDOM getDOM
} from "./chunk-H4LQPAO2.js"; } from "./chunk-QTXEQHHS.js";
import "./chunk-OUSM42MY.js"; import "./chunk-OUSM42MY.js";
import { import {
ApplicationRef, ApplicationRef,
ChangeDetectorRef, ChangeDetectorRef,
DestroyRef,
Directive, Directive,
ElementRef, ElementRef,
EventEmitter, EventEmitter,
Host, Host,
Inject, Inject,
Injectable, Injectable,
InjectionToken,
Injector,
Input, Input,
NgModule, NgModule,
Optional, Optional,
Output, Output,
Renderer2, Renderer2,
RuntimeError,
Self, Self,
SkipSelf, SkipSelf,
Version, Version,
afterNextRender, afterNextRender,
booleanAttribute, booleanAttribute,
computed,
forwardRef,
inject,
isPromise, isPromise,
isSubscribable, isSubscribable,
setClassMetadata, setClassMetadata,
signal,
untracked,
ɵɵInheritDefinitionFeature, ɵɵInheritDefinitionFeature,
ɵɵNgOnChangesFeature, ɵɵNgOnChangesFeature,
ɵɵProvidersFeature, ɵɵProvidersFeature,
ɵɵattribute, ɵɵattribute,
ɵɵclassProp, ɵɵclassProp,
ɵɵdefineDirective, ɵɵdefineDirective,
ɵɵdefineInjectable,
ɵɵdefineInjector,
ɵɵdefineNgModule, ɵɵdefineNgModule,
ɵɵdirectiveInject, ɵɵdirectiveInject,
ɵɵgetInheritedFactory, ɵɵgetInheritedFactory,
ɵɵlistener ɵɵlistener
} from "./chunk-FVA7C6JK.js"; } from "./chunk-XYAQCRC2.js";
import {
DestroyRef,
InjectionToken,
Injector,
RuntimeError,
computed,
forwardRef,
inject,
signal,
untracked,
ɵɵdefineInjectable,
ɵɵdefineInjector
} from "./chunk-GFLMLXUS.js";
import { import {
forkJoin forkJoin
} from "./chunk-HWYXSU2G.js"; } from "./chunk-HWYXSU2G.js";

File diff suppressed because one or more lines are too long

View File

@ -34,13 +34,14 @@ import {
withI18nSupport, withI18nSupport,
withIncrementalHydration, withIncrementalHydration,
withNoHttpTransferCache withNoHttpTransferCache
} from "./chunk-DYWB3JMR.js"; } from "./chunk-OUP2T4DW.js";
import "./chunk-5DRVFSXL.js"; import "./chunk-GND3ZHQO.js";
import { import {
getDOM getDOM
} from "./chunk-H4LQPAO2.js"; } from "./chunk-QTXEQHHS.js";
import "./chunk-OUSM42MY.js"; import "./chunk-OUSM42MY.js";
import "./chunk-FVA7C6JK.js"; import "./chunk-XYAQCRC2.js";
import "./chunk-GFLMLXUS.js";
import "./chunk-HWYXSU2G.js"; import "./chunk-HWYXSU2G.js";
import "./chunk-JRFR6BLO.js"; import "./chunk-JRFR6BLO.js";
import "./chunk-MARUHEWW.js"; import "./chunk-MARUHEWW.js";

View File

@ -1,7 +1,7 @@
import { import {
Title Title
} from "./chunk-DYWB3JMR.js"; } from "./chunk-OUP2T4DW.js";
import "./chunk-5DRVFSXL.js"; import "./chunk-GND3ZHQO.js";
import { import {
HashLocationStrategy, HashLocationStrategy,
LOCATION_INITIALIZED, LOCATION_INITIALIZED,
@ -9,7 +9,7 @@ import {
LocationStrategy, LocationStrategy,
PathLocationStrategy, PathLocationStrategy,
ViewportScroller ViewportScroller
} from "./chunk-H4LQPAO2.js"; } from "./chunk-QTXEQHHS.js";
import "./chunk-OUSM42MY.js"; import "./chunk-OUSM42MY.js";
import { import {
APP_BOOTSTRAP_LISTENER, APP_BOOTSTRAP_LISTENER,
@ -20,69 +20,71 @@ import {
Component, Component,
Console, Console,
ContentChildren, ContentChildren,
DOCUMENT,
DestroyRef,
Directive, Directive,
ENVIRONMENT_INITIALIZER,
ElementRef, ElementRef,
EnvironmentInjector,
EventEmitter, EventEmitter,
HostAttributeToken, HostAttributeToken,
HostBinding, HostBinding,
HostListener, HostListener,
INTERNAL_APPLICATION_ERROR_HANDLER,
IS_ENABLED_BLOCKING_INITIAL_NAVIGATION, IS_ENABLED_BLOCKING_INITIAL_NAVIGATION,
Injectable, Injectable,
InjectionToken,
Injector,
Input, Input,
NgModule, NgModule,
NgModuleFactory$1, NgModuleFactory$1,
NgZone, NgZone,
Optional, Optional,
Output, Output,
PendingTasksInternal,
Renderer2, Renderer2,
RuntimeError,
SkipSelf, SkipSelf,
Version, Version,
ViewContainerRef, ViewContainerRef,
afterNextRender, afterNextRender,
booleanAttribute, booleanAttribute,
createEnvironmentInjector, createEnvironmentInjector,
inject,
input, input,
isInjectable,
isNgModule, isNgModule,
isPromise, isPromise,
isStandalone,
makeEnvironmentProviders,
performanceMarkFeature, performanceMarkFeature,
provideAppInitializer, provideAppInitializer,
reflectComponentType, reflectComponentType,
runInInjectionContext,
setClassMetadata, setClassMetadata,
signal,
untracked,
ɵɵNgOnChangesFeature, ɵɵNgOnChangesFeature,
ɵɵattribute, ɵɵattribute,
ɵɵcontentQuery, ɵɵcontentQuery,
ɵɵdefineComponent, ɵɵdefineComponent,
ɵɵdefineDirective, ɵɵdefineDirective,
ɵɵdefineInjectable,
ɵɵdefineInjector,
ɵɵdefineNgModule, ɵɵdefineNgModule,
ɵɵdirectiveInject, ɵɵdirectiveInject,
ɵɵelement, ɵɵelement,
ɵɵgetInheritedFactory, ɵɵgetInheritedFactory,
ɵɵinject,
ɵɵinjectAttribute, ɵɵinjectAttribute,
ɵɵinvalidFactory, ɵɵinvalidFactory,
ɵɵlistener, ɵɵlistener,
ɵɵloadQuery, ɵɵloadQuery,
ɵɵqueryRefresh, ɵɵqueryRefresh,
ɵɵsanitizeUrlOrResourceUrl ɵɵsanitizeUrlOrResourceUrl
} from "./chunk-FVA7C6JK.js"; } from "./chunk-XYAQCRC2.js";
import {
DOCUMENT,
DestroyRef,
ENVIRONMENT_INITIALIZER,
EnvironmentInjector,
INTERNAL_APPLICATION_ERROR_HANDLER,
InjectionToken,
Injector,
PendingTasksInternal,
RuntimeError,
inject,
isInjectable,
isStandalone,
makeEnvironmentProviders,
runInInjectionContext,
signal,
untracked,
ɵɵdefineInjectable,
ɵɵdefineInjector,
ɵɵinject
} from "./chunk-GFLMLXUS.js";
import { import {
defer, defer,
isObservable isObservable

File diff suppressed because one or more lines are too long

View File

@ -1,79 +1,88 @@
{ {
"hash": "f4c7eaa2", "hash": "26d31ed9",
"configHash": "d859ec53", "configHash": "69ef457b",
"lockfileHash": "9b1c4210", "lockfileHash": "9b1c4210",
"browserHash": "6942cbde", "browserHash": "2055d91d",
"optimized": { "optimized": {
"@angular/common": { "@angular/common": {
"src": "../../../../../../node_modules/@angular/common/fesm2022/common.mjs", "src": "../../../../../../node_modules/@angular/common/fesm2022/common.mjs",
"file": "@angular_common.js", "file": "@angular_common.js",
"fileHash": "92f641aa", "fileHash": "58788c60",
"needsInterop": false "needsInterop": false
}, },
"@angular/common/http": { "@angular/common/http": {
"src": "../../../../../../node_modules/@angular/common/fesm2022/http.mjs", "src": "../../../../../../node_modules/@angular/common/fesm2022/http.mjs",
"file": "@angular_common_http.js", "file": "@angular_common_http.js",
"fileHash": "3a8b8614", "fileHash": "28e8e915",
"needsInterop": false "needsInterop": false
}, },
"@angular/core": { "@angular/core": {
"src": "../../../../../../node_modules/@angular/core/fesm2022/core.mjs", "src": "../../../../../../node_modules/@angular/core/fesm2022/core.mjs",
"file": "@angular_core.js", "file": "@angular_core.js",
"fileHash": "b1ee9355", "fileHash": "a1777273",
"needsInterop": false
},
"@angular/core/rxjs-interop": {
"src": "../../../../../../node_modules/@angular/core/fesm2022/rxjs-interop.mjs",
"file": "@angular_core_rxjs-interop.js",
"fileHash": "5aac569e",
"needsInterop": false "needsInterop": false
}, },
"@angular/forms": { "@angular/forms": {
"src": "../../../../../../node_modules/@angular/forms/fesm2022/forms.mjs", "src": "../../../../../../node_modules/@angular/forms/fesm2022/forms.mjs",
"file": "@angular_forms.js", "file": "@angular_forms.js",
"fileHash": "5842af9d", "fileHash": "76b8143b",
"needsInterop": false "needsInterop": false
}, },
"@angular/platform-browser": { "@angular/platform-browser": {
"src": "../../../../../../node_modules/@angular/platform-browser/fesm2022/platform-browser.mjs", "src": "../../../../../../node_modules/@angular/platform-browser/fesm2022/platform-browser.mjs",
"file": "@angular_platform-browser.js", "file": "@angular_platform-browser.js",
"fileHash": "f65b040d", "fileHash": "0219e3bd",
"needsInterop": false "needsInterop": false
}, },
"@angular/router": { "@angular/router": {
"src": "../../../../../../node_modules/@angular/router/fesm2022/router.mjs", "src": "../../../../../../node_modules/@angular/router/fesm2022/router.mjs",
"file": "@angular_router.js", "file": "@angular_router.js",
"fileHash": "f605abad", "fileHash": "e1237275",
"needsInterop": false "needsInterop": false
}, },
"@google/genai": { "@google/genai": {
"src": "../../../../../../node_modules/@google/genai/dist/web/index.mjs", "src": "../../../../../../node_modules/@google/genai/dist/web/index.mjs",
"file": "@google_genai.js", "file": "@google_genai.js",
"fileHash": "b3b8b992", "fileHash": "89bd7952",
"needsInterop": false "needsInterop": false
}, },
"rxjs": { "rxjs": {
"src": "../../../../../../node_modules/rxjs/dist/esm5/index.js", "src": "../../../../../../node_modules/rxjs/dist/esm5/index.js",
"file": "rxjs.js", "file": "rxjs.js",
"fileHash": "0cb397ac", "fileHash": "460c479b",
"needsInterop": false "needsInterop": false
}, },
"rxjs/operators": { "rxjs/operators": {
"src": "../../../../../../node_modules/rxjs/dist/esm5/operators/index.js", "src": "../../../../../../node_modules/rxjs/dist/esm5/operators/index.js",
"file": "rxjs_operators.js", "file": "rxjs_operators.js",
"fileHash": "e706ebfa", "fileHash": "0c6ce2bb",
"needsInterop": false "needsInterop": false
} }
}, },
"chunks": { "chunks": {
"chunk-DYWB3JMR": { "chunk-OUP2T4DW": {
"file": "chunk-DYWB3JMR.js" "file": "chunk-OUP2T4DW.js"
}, },
"chunk-5DRVFSXL": { "chunk-GND3ZHQO": {
"file": "chunk-5DRVFSXL.js" "file": "chunk-GND3ZHQO.js"
}, },
"chunk-H4LQPAO2": { "chunk-QTXEQHHS": {
"file": "chunk-H4LQPAO2.js" "file": "chunk-QTXEQHHS.js"
}, },
"chunk-OUSM42MY": { "chunk-OUSM42MY": {
"file": "chunk-OUSM42MY.js" "file": "chunk-OUSM42MY.js"
}, },
"chunk-FVA7C6JK": { "chunk-XYAQCRC2": {
"file": "chunk-FVA7C6JK.js" "file": "chunk-XYAQCRC2.js"
},
"chunk-GFLMLXUS": {
"file": "chunk-GFLMLXUS.js"
}, },
"chunk-HWYXSU2G": { "chunk-HWYXSU2G": {
"file": "chunk-HWYXSU2G.js" "file": "chunk-HWYXSU2G.js"

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -5,19 +5,25 @@ import {
import { import {
APP_BOOTSTRAP_LISTENER, APP_BOOTSTRAP_LISTENER,
ApplicationRef, ApplicationRef,
Inject,
Injectable,
NgModule,
NgZone,
TransferState,
makeStateKey,
performanceMarkFeature,
setClassMetadata,
ɵɵdefineNgModule
} from "./chunk-XYAQCRC2.js";
import {
DOCUMENT, DOCUMENT,
DestroyRef, DestroyRef,
EnvironmentInjector, EnvironmentInjector,
Inject,
Injectable,
InjectionToken, InjectionToken,
Injector, Injector,
NgModule,
NgZone,
PendingTasks, PendingTasks,
ResourceImpl, ResourceImpl,
RuntimeError, RuntimeError,
TransferState,
assertInInjectionContext, assertInInjectionContext,
computed, computed,
encapsulateResourceError, encapsulateResourceError,
@ -25,17 +31,13 @@ import {
inject, inject,
linkedSignal, linkedSignal,
makeEnvironmentProviders, makeEnvironmentProviders,
makeStateKey,
performanceMarkFeature,
runInInjectionContext, runInInjectionContext,
setClassMetadata,
signal, signal,
truncateMiddle, truncateMiddle,
ɵɵdefineInjectable, ɵɵdefineInjectable,
ɵɵdefineInjector, ɵɵdefineInjector,
ɵɵdefineNgModule,
ɵɵinject ɵɵinject
} from "./chunk-FVA7C6JK.js"; } from "./chunk-GFLMLXUS.js";
import { import {
Observable, Observable,
concatMap, concatMap,
@ -2776,4 +2778,4 @@ export {
* License: MIT * License: MIT
*) *)
*/ */
//# sourceMappingURL=chunk-5DRVFSXL.js.map //# sourceMappingURL=chunk-GND3ZHQO.js.map

View File

@ -1,13 +1,13 @@
import { import {
withHttpTransferCache withHttpTransferCache
} from "./chunk-5DRVFSXL.js"; } from "./chunk-GND3ZHQO.js";
import { import {
CommonModule, CommonModule,
DomAdapter, DomAdapter,
PLATFORM_BROWSER_ID, PLATFORM_BROWSER_ID,
getDOM, getDOM,
setRootDomAdapter setRootDomAdapter
} from "./chunk-H4LQPAO2.js"; } from "./chunk-QTXEQHHS.js";
import { import {
XhrFactory, XhrFactory,
parseCookieValue parseCookieValue
@ -19,15 +19,9 @@ import {
ApplicationRef, ApplicationRef,
CSP_NONCE, CSP_NONCE,
Console, Console,
DOCUMENT,
ENVIRONMENT_INITIALIZER,
ErrorHandler,
INJECTOR_SCOPE,
IS_ENABLED_BLOCKING_INITIAL_NAVIGATION, IS_ENABLED_BLOCKING_INITIAL_NAVIGATION,
Inject, Inject,
Injectable, Injectable,
InjectionToken,
Injector,
MAX_ANIMATION_TIMEOUT, MAX_ANIMATION_TIMEOUT,
NgModule, NgModule,
NgZone, NgZone,
@ -36,7 +30,6 @@ import {
PLATFORM_INITIALIZER, PLATFORM_INITIALIZER,
RendererFactory2, RendererFactory2,
RendererStyleFlags2, RendererStyleFlags2,
RuntimeError,
SecurityContext, SecurityContext,
TESTABILITY, TESTABILITY,
TESTABILITY_GETTER, TESTABILITY_GETTER,
@ -45,9 +38,6 @@ import {
TracingService, TracingService,
Version, Version,
ViewEncapsulation, ViewEncapsulation,
XSS_SECURITY_URL,
ZONELESS_ENABLED,
_global,
_sanitizeHtml, _sanitizeHtml,
_sanitizeUrl, _sanitizeUrl,
allowSanitizationBypassAndThrow, allowSanitizationBypassAndThrow,
@ -57,12 +47,7 @@ import {
bypassSanitizationTrustStyle, bypassSanitizationTrustStyle,
bypassSanitizationTrustUrl, bypassSanitizationTrustUrl,
createPlatformFactory, createPlatformFactory,
formatRuntimeError,
forwardRef,
getAnimationElementRemovalRegistry,
inject,
internalCreateApplication, internalCreateApplication,
makeEnvironmentProviders,
platformCore, platformCore,
setClassMetadata, setClassMetadata,
setDocument, setDocument,
@ -71,11 +56,28 @@ import {
withEventReplay, withEventReplay,
withI18nSupport, withI18nSupport,
withIncrementalHydration, withIncrementalHydration,
ɵɵdefineNgModule
} from "./chunk-XYAQCRC2.js";
import {
DOCUMENT,
ENVIRONMENT_INITIALIZER,
ErrorHandler,
INJECTOR_SCOPE,
InjectionToken,
Injector,
RuntimeError,
XSS_SECURITY_URL,
ZONELESS_ENABLED,
_global,
formatRuntimeError,
forwardRef,
getAnimationElementRemovalRegistry,
inject,
makeEnvironmentProviders,
ɵɵdefineInjectable, ɵɵdefineInjectable,
ɵɵdefineInjector, ɵɵdefineInjector,
ɵɵdefineNgModule,
ɵɵinject ɵɵinject
} from "./chunk-FVA7C6JK.js"; } from "./chunk-GFLMLXUS.js";
import { import {
__spreadValues __spreadValues
} from "./chunk-GOMI4DH3.js"; } from "./chunk-GOMI4DH3.js";
@ -2073,4 +2075,4 @@ export {
* License: MIT * License: MIT
*) *)
*/ */
//# sourceMappingURL=chunk-DYWB3JMR.js.map //# sourceMappingURL=chunk-OUP2T4DW.js.map

View File

@ -3,18 +3,13 @@ import {
Attribute, Attribute,
ChangeDetectorRef, ChangeDetectorRef,
DEFAULT_CURRENCY_CODE, DEFAULT_CURRENCY_CODE,
DOCUMENT,
DestroyRef,
Directive, Directive,
ElementRef, ElementRef,
Host, Host,
IMAGE_CONFIG, IMAGE_CONFIG,
IMAGE_CONFIG_DEFAULTS, IMAGE_CONFIG_DEFAULTS,
INTERNAL_APPLICATION_ERROR_HANDLER,
Inject, Inject,
Injectable, Injectable,
InjectionToken,
Injector,
Input, Input,
IterableDiffers, IterableDiffers,
KeyValueDiffers, KeyValueDiffers,
@ -27,37 +22,44 @@ import {
Pipe, Pipe,
Renderer2, Renderer2,
RendererStyleFlags2, RendererStyleFlags2,
RuntimeError,
TemplateRef, TemplateRef,
Version, Version,
ViewContainerRef, ViewContainerRef,
booleanAttribute, booleanAttribute,
createNgModule, createNgModule,
findLocaleData, findLocaleData,
formatRuntimeError,
getLocaleCurrencyCode, getLocaleCurrencyCode,
getLocalePluralCase, getLocalePluralCase,
inject,
isPromise, isPromise,
isSubscribable, isSubscribable,
numberAttribute, numberAttribute,
performanceMarkFeature, performanceMarkFeature,
registerLocaleData, registerLocaleData,
setClassMetadata, setClassMetadata,
stringify,
untracked,
unwrapSafeValue, unwrapSafeValue,
ɵɵNgOnChangesFeature, ɵɵNgOnChangesFeature,
ɵɵdefineDirective, ɵɵdefineDirective,
ɵɵdefineInjectable,
ɵɵdefineInjector,
ɵɵdefineNgModule, ɵɵdefineNgModule,
ɵɵdefinePipe, ɵɵdefinePipe,
ɵɵdirectiveInject, ɵɵdirectiveInject,
ɵɵinject,
ɵɵinjectAttribute, ɵɵinjectAttribute,
ɵɵstyleProp ɵɵstyleProp
} from "./chunk-FVA7C6JK.js"; } from "./chunk-XYAQCRC2.js";
import {
DOCUMENT,
DestroyRef,
INTERNAL_APPLICATION_ERROR_HANDLER,
InjectionToken,
Injector,
RuntimeError,
formatRuntimeError,
inject,
stringify,
untracked,
ɵɵdefineInjectable,
ɵɵdefineInjector,
ɵɵinject
} from "./chunk-GFLMLXUS.js";
import { import {
Subject Subject
} from "./chunk-MARUHEWW.js"; } from "./chunk-MARUHEWW.js";
@ -5188,4 +5190,4 @@ export {
* License: MIT * License: MIT
*) *)
*/ */
//# sourceMappingURL=chunk-H4LQPAO2.js.map //# sourceMappingURL=chunk-QTXEQHHS.js.map

File diff suppressed because one or more lines are too long

54
api/README.md Normal file
View File

@ -0,0 +1,54 @@
# NewTube Unified Search API
This document describes the unified search endpoint used by the frontend SearchBar and Search page.
Base URL
- Development: http://localhost:4000/api
- Production: /api
Endpoint
- GET /api/search
Query parameters
- q (string, required, min length 2)
- providers (string, optional): Comma-separated list of provider ids. Allowed values: yt, dm, tw, pt, od, ru. Defaults to all when omitted or invalid.
- page (integer, optional): Page index starting at 1. Default: 1.
- pageSize (integer, optional): Page size, max 50. Default: 24.
- sort (string, optional): Reserved for future use.
Response
```
{
"q": "string",
"providers": ["yt", "dm", "tw", "pt", "od", "ru"],
"groups": {
"yt": [
{ "id": "string", "title": "string", "thumbnail": "string", "uploaderName": "string", "url": "string", "type": "video" }
],
"dm": [],
"tw": [],
"pt": [],
"od": [],
"ru": []
},
"page": 1,
"pageSize": 24
}
```
Error responses
- 400 { error: "invalid_query", details: "min_length_2" }
- 500 { error: "search_failed", details: "..." }
Providers
- yt: YouTube
- dm: Dailymotion
- tw: Twitch
- pt: PeerTube
- od: Odysee
- ru: Rumble
Notes
- Each provider is queried in parallel with a per-provider limit equal to `pageSize`.
- The endpoint currently does not expose a `total` field; the frontend should offer a simple Next page affordance or infinite scroll when appropriate.
- Future: support for `sort`.

Binary file not shown.

BIN
server/db/newtube.db Normal file

Binary file not shown.

View File

@ -2,6 +2,24 @@ import express from 'express';
import helmet from 'helmet'; import helmet from 'helmet';
import cors from 'cors'; import cors from 'cors';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
// Configuration CORS
const corsOptions = {
origin: ['http://localhost:4200', 'http://localhost:4000', 'http://localhost:3000'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400 // 24h
};
// Middleware de logging
const requestLogger = (req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl}`);
console.log('Headers:', JSON.stringify(req.headers, null, 2));
console.log('Query:', JSON.stringify(req.query, null, 2));
console.log('Body:', JSON.stringify(req.body, null, 2));
next();
};
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
@ -117,11 +135,16 @@ app.use(helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' }, crossOriginResourcePolicy: { policy: 'cross-origin' },
})); }));
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser()); app.use(cookieParser());
app.use(cors({ app.use(cors(corsOptions));
origin: true, app.options('*', cors(corsOptions)); // Pré-vol CORS
credentials: true,
})); // Logging des requêtes
app.use(requestLogger);
// Routes API
app.use('/api', r);
// Downloads directory // Downloads directory
const downloadsRoot = path.join(process.cwd(), 'tmp', 'downloads'); const downloadsRoot = path.join(process.cwd(), 'tmp', 'downloads');
@ -1390,10 +1413,20 @@ app.all('/api/twitch-auth/*', (req, res) => forwardJson(req, res, 'https://id.tw
// -------------------- Unified search endpoint (GET) -------------------- // -------------------- Unified search endpoint (GET) --------------------
app.get('/api/search', async (req, res) => { app.get('/api/search', async (req, res) => {
try { try {
console.log('[SEARCH] Requête reçue - Query:', req.query);
const { q, providers } = req.query; const { q, providers } = req.query;
if (!q || typeof q !== 'string' || q.trim().length === 0) {
return res.status(400).json({ error: 'missing_query' }); // Validation des paramètres
if (!q || typeof q !== 'string' || q.trim().length < 2) {
console.log('[SEARCH] Requête invalide - q manquant ou trop court:', q);
return res.status(400).json({ error: 'q is required and must be at least 2 characters long' });
} }
const pageNum = Math.max(1, Number(req.query.page || 1));
const pageSize = Math.min(50, Math.max(1, Number(req.query.pageSize || 24)));
// Optional sort parameter (normalized to known set)
const allowedSort = new Set(['relevance', 'date', 'views']);
let sort = (typeof req.query.sort === 'string' ? req.query.sort.trim().toLowerCase() : 'relevance');
if (!allowedSort.has(sort)) sort = 'relevance';
// Validate and normalize providers list (default to all supported when none/invalid) // Validate and normalize providers list (default to all supported when none/invalid)
const requested = typeof providers === 'string' ? String(providers) : ''; const requested = typeof providers === 'string' ? String(providers) : '';
const validProviders = validateProviders(requested); const validProviders = validateProviders(requested);
@ -1403,8 +1436,8 @@ app.get('/api/search', async (req, res) => {
validProviders.map((providerId) => { validProviders.map((providerId) => {
const mod = providerRegistry[/** @type {any} */(providerId)]; const mod = providerRegistry[/** @type {any} */(providerId)];
if (!mod || typeof mod.search !== 'function') return Promise.resolve([]); if (!mod || typeof mod.search !== 'function') return Promise.resolve([]);
// Basic options: limit per provider; could be extended with page, etc. // Basic options include pagination and sort hints
return Promise.resolve().then(() => mod.search(q, { limit: 10 })); return Promise.resolve().then(() => mod.search(q, { limit: pageSize, page: pageNum, sort }));
}) })
); );
@ -1420,7 +1453,7 @@ app.get('/api/search', async (req, res) => {
} }
}); });
return res.json({ q, providers: validProviders, groups }); return res.json({ q, providers: validProviders, groups, page: pageNum, pageSize, sort });
} catch (e) { } catch (e) {
return res.status(500).json({ error: 'search_failed', details: String(e?.message || e) }); return res.status(500).json({ error: 'search_failed', details: String(e?.message || e) });
} }

View File

@ -10,18 +10,22 @@
* @property {string=} type * @property {string=} type
*/ */
/** @type {{ id: 'yt', label: string, search: (q: string, opts: { limit: number, page?: number }) => Promise<Suggestion[]> }} */ /** @type {{ id: 'yt', label: string, search: (q: string, opts: { limit: number, page?: number, sort?: 'relevance'|'date'|'views' }) => Promise<Suggestion[]> }} */
const handler = { const handler = {
id: 'yt', id: 'yt',
label: 'YouTube', label: 'YouTube',
async search(q, opts) { async search(q, opts) {
const { limit = 10 } = opts; const { limit = 10, sort = 'relevance' } = opts || {};
try { try {
const API_KEY = process.env.YOUTUBE_API_KEY; const API_KEY = process.env.YOUTUBE_API_KEY;
if (!API_KEY) { if (!API_KEY) {
throw new Error('YOUTUBE_API_KEY not configured'); throw new Error('YOUTUBE_API_KEY not configured');
} }
let order = 'relevance';
if (sort === 'date') order = 'date';
else if (sort === 'views') order = 'viewCount';
const response = await fetch( const response = await fetch(
`https://www.googleapis.com/youtube/v3/search?` + `https://www.googleapis.com/youtube/v3/search?` +
new URLSearchParams({ new URLSearchParams({
@ -30,7 +34,7 @@ const handler = {
type: 'video', type: 'video',
maxResults: Math.min(limit, 50).toString(), maxResults: Math.min(limit, 50).toString(),
key: API_KEY, key: API_KEY,
order: 'relevance' order
}) })
); );

View File

@ -0,0 +1,24 @@
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult, SearchItem, ProviderId } from '../models';
export abstract class HttpAdapter implements ProviderAdapter {
abstract key: ProviderId;
abstract label: string;
constructor(protected http: HttpClient) {}
abstract search(params: ProviderSearchParams, signal: AbortSignal): Promise<SearchResult>;
protected mapItem(partial: Partial<SearchItem> & { id: string; title: string; watchUrl: string }): SearchItem {
return {
provider: this.key,
channel: undefined,
durationSec: undefined,
views: undefined,
publishedAt: undefined,
isLive: false,
isShort: false,
thumbUrl: undefined,
...partial,
} as SearchItem;
}
}

View File

@ -0,0 +1,26 @@
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
export class DmAdapter implements ProviderAdapter {
key = 'dm' as const;
label = 'Dailymotion';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'dm' } }));
const items = (res?.groups?.dm || []).map((it: any) => ({
id: it.id,
provider: this.key,
title: it.title,
channel: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: it.type === 'live',
isShort: it.isShort === true
}));
return { items, total: Array.isArray(res?.groups?.dm) ? res.groups.dm.length : 0 };
}
}

View File

@ -0,0 +1,26 @@
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
export class OdAdapter implements ProviderAdapter {
key = 'od' as const;
label = 'Odysee';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'od' } }));
const items = (res?.groups?.od || []).map((it: any) => ({
id: it.id,
provider: this.key,
title: it.title,
channel: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: false,
isShort: it.isShort === true
}));
return { items, total: Array.isArray(res?.groups?.od) ? res.groups.od.length : 0 };
}
}

View File

@ -0,0 +1,26 @@
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
export class PtAdapter implements ProviderAdapter {
key = 'pt' as const;
label = 'PeerTube';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'pt' } }));
const items = (res?.groups?.pt || []).map((it: any) => ({
id: it.id,
provider: this.key,
title: it.title,
channel: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: false,
isShort: false
}));
return { items, total: Array.isArray(res?.groups?.pt) ? res.groups.pt.length : 0 };
}
}

View File

@ -0,0 +1,26 @@
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
export class RuAdapter implements ProviderAdapter {
key = 'ru' as const;
label = 'Rumble';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'ru' } }));
const items = (res?.groups?.ru || []).map((it: any) => ({
id: it.id,
provider: this.key,
title: it.title,
channel: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: false,
isShort: false
}));
return { items, total: Array.isArray(res?.groups?.ru) ? res.groups.ru.length : 0 };
}
}

View File

@ -0,0 +1,26 @@
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
export class TwAdapter implements ProviderAdapter {
key = 'tw' as const;
label = 'Twitch';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'tw' } }));
const items = (res?.groups?.tw || []).map((it: any) => ({
id: it.id,
provider: this.key,
title: it.title,
channel: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: it.type === 'live',
isShort: false
}));
return { items, total: Array.isArray(res?.groups?.tw) ? res.groups.tw.length : 0 };
}
}

View File

@ -0,0 +1,26 @@
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
export class YtAdapter implements ProviderAdapter {
key = 'yt' as const;
label = 'YouTube';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'yt' } }));
const items = (res?.groups?.yt || []).map((it: any) => ({
id: it.id,
provider: this.key,
title: it.title,
channel: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: it.type === 'live',
isShort: it.isShort === true
}));
return { items, total: Array.isArray(res?.groups?.yt) ? res.groups.yt.length : 0 };
}
}

21
src/app/search/api.v1.ts Normal file
View File

@ -0,0 +1,21 @@
export type ProviderId = 'yt' | 'dm' | 'tw' | 'pt' | 'od' | 'ru';
export interface SuggestionItemV1 {
title: string;
id: string;
duration?: number;
isShort?: boolean;
thumbnail?: string;
uploaderName?: string;
url?: string;
type?: string;
}
export interface SearchResponseV1 {
q: string;
providers: ProviderId[];
groups: Record<ProviderId, SuggestionItemV1[]>;
page?: number;
pageSize?: number;
sort?: 'relevance' | 'date' | 'views';
}

36
src/app/search/models.ts Normal file
View File

@ -0,0 +1,36 @@
export type ProviderId = 'yt'|'dm'|'tw'|'pt'|'od'|'ru';
export interface SearchItem {
id: string;
provider: ProviderId;
title: string;
channel?: string;
durationSec?: number;
views?: number;
publishedAt?: string; // ISO
isLive?: boolean;
isShort?: boolean;
thumbUrl?: string;
watchUrl: string; // internal or external
}
export interface SearchResult {
items: SearchItem[];
total?: number;
nextPageToken?: string; // or page: string|number
}
export interface ProviderSearchParams {
q: string;
pageToken?: string; // or page
sort?: 'relevance'|'date'|'views'|'duration';
time?: 'today'|'7d'|'30d'|'all';
length?: 'short'|'medium'|'long'|'all';
type?: 'video'|'live'|'shorts'|'all';
}
export interface ProviderAdapter {
key: ProviderId;
label: string;
search(params: ProviderSearchParams, signal: AbortSignal): Promise<SearchResult>;
}

View File

@ -1,49 +1,126 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, combineLatest, distinctUntilChanged, debounceTime, map, switchMap } from 'rxjs'; import { BehaviorSubject, combineLatest, distinctUntilChanged, debounceTime, map, switchMap, filter, shareReplay, from, of } from 'rxjs';
import type { ProviderId } from '../core/providers/provider-registry'; import type { ProviderId } from '../core/providers/provider-registry';
import type { SuggestionItemV1, SearchResponseV1 } from './api.v1';
import { YtAdapter } from './adapters/yt';
import { DmAdapter } from './adapters/dm';
import { TwAdapter } from './adapters/tw';
import { PtAdapter } from './adapters/pt';
import { OdAdapter } from './adapters/od';
import { RuAdapter } from './adapters/ru';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from './models';
export interface SuggestionItem { export type SuggestionItem = SuggestionItemV1;
title: string; export type SearchResponse = SearchResponseV1;
id: string;
duration?: number;
isShort?: boolean;
thumbnail?: string;
uploaderName?: string;
url?: string;
type?: string; // 'video' | 'channel' | ...
}
export interface SearchResponse { type CacheKey = string;
q: string; interface CacheEntry { t: number; data: SearchResult; }
providers: ProviderId[];
groups: Record<ProviderId, SuggestionItem[]>;
}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SearchService { export class SearchService {
private http = inject(HttpClient); private http = inject(HttpClient);
// Subjects for params from UI/URL
readonly q$ = new BehaviorSubject<string>(''); readonly q$ = new BehaviorSubject<string>('');
readonly providers$ = new BehaviorSubject<ProviderId[] | 'all'>('all'); readonly providers$ = new BehaviorSubject<ProviderId[] | 'all'>('all');
readonly page$ = new BehaviorSubject<number>(1);
readonly pageSize$ = new BehaviorSubject<number>(24);
readonly sort$ = new BehaviorSubject<'relevance' | 'date' | 'views' | 'duration'>('relevance');
readonly params$ = combineLatest([this.q$, this.providers$]).pipe( // In-memory cache 60s per (provider, q, params)
private cache = new Map<CacheKey, CacheEntry>();
private cacheTtlMs = 60_000;
// Adapters registry
private adapters: Record<ProviderId, ProviderAdapter> = {
yt: new YtAdapter(this.http),
dm: new DmAdapter(this.http),
tw: new TwAdapter(this.http),
pt: new PtAdapter(this.http),
od: new OdAdapter(this.http),
ru: new RuAdapter(this.http)
};
readonly params$ = combineLatest([this.q$, this.providers$, this.page$, this.pageSize$, this.sort$]).pipe(
debounceTime(120), debounceTime(120),
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)) distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))
); );
// Public request stream: aggregates groups by provider, runs adapters in parallel with timeout/abort/retry
readonly request$ = this.params$.pipe( readonly request$ = this.params$.pipe(
switchMap(([q, prov]) => { filter(([q]) => typeof q === 'string' && q.trim().length >= 2),
const providersParam = (prov === 'all') switchMap(([q, prov, page, pageSize, sort]) => from(this.runAdapters({
? 'yt,dm,tw,pt,od,ru' q: String(q),
: (Array.isArray(prov) && prov.length > 0 ? prov.join(',') : ''); pageToken: String(page || 1),
return this.http.get<SearchResponse>('/api/search', { sort: (sort as any) || 'relevance',
params: { q, providers: providersParam } // global filter knobs could be added here: time/length/type in the future
}); }, prov)))
}) ,
// Map normalized items to legacy "groups" response expected by UI
map((byProvider) => {
const groups = Object.entries(byProvider).reduce((acc, [pid, result]) => {
const arr: SuggestionItemV1[] = (result?.items || []).map(it => ({
id: it.id,
title: it.title,
duration: it.durationSec,
isShort: it.isShort,
thumbnail: it.thumbUrl,
uploaderName: it.channel,
url: it.watchUrl,
type: it.isLive ? 'live' : 'video'
}));
(acc as any)[pid as ProviderId] = arr;
return acc;
}, {} as Record<ProviderId, SuggestionItemV1[]>);
const providers = Object.keys(byProvider) as ProviderId[];
const resp: SearchResponse = { q: this.q$.value, providers, groups };
return resp;
}),
shareReplay(1)
); );
// Orchestrate all active providers in parallel with timeout(8s), retry(1) and abort on param change.
private async runAdapters(params: ProviderSearchParams, prov: ProviderId[] | 'all') {
const active: ProviderId[] = prov === 'all' ? ['yt','dm','tw','pt','od','ru'] : (Array.isArray(prov) ? prov : []);
const controller = new AbortController();
const signal = controller.signal;
const tasks = active.map(async (pid) => {
const adapter = this.adapters[pid];
if (!adapter) return [pid, { items: [] as any[] } as SearchResult] as const;
const key: CacheKey = `${pid}|${params.q}|${params.pageToken}|${params.sort}`;
const now = Date.now();
const cached = this.cache.get(key);
if (cached && (now - cached.t) < this.cacheTtlMs) {
return [pid, cached.data] as const;
}
const perProviderTimeout = 8000;
const withTimeout = <T>(p: Promise<T>): Promise<T> => new Promise((resolve, reject) => {
const tid = setTimeout(() => reject(new Error('timeout')), perProviderTimeout);
p.then(v => { clearTimeout(tid); resolve(v); }).catch(e => { clearTimeout(tid); reject(e); });
});
const attempt = async (): Promise<SearchResult> => withTimeout(adapter.search(params, signal));
try {
const res = await attempt().catch(async (e) => {
// retry once on network-like errors
if (e && (e.name === 'AbortError' || String(e.message || '').includes('abort'))) throw e;
try { return await attempt(); } catch (err) { throw err; }
});
this.cache.set(key, { t: Date.now(), data: res });
return [pid, res] as const;
} catch {
// Swallow errors per provider, return empty
return [pid, { items: [] }] as const;
}
});
const results = await Promise.all(tasks);
return Object.fromEntries(results) as Record<ProviderId, SearchResult>;
}
// Convenience setter helpers // Convenience setter helpers
setQuery(q: string) { this.q$.next(q || ''); } setQuery(q: string) { this.q$.next(q || ''); }
setProviders(list: ProviderId[] | 'all') { this.providers$.next(list && (Array.isArray(list) ? list : 'all')); } setProviders(list: ProviderId[] | 'all') { this.providers$.next(list && (Array.isArray(list) ? list : 'all')); }
setPage(page: number) { this.page$.next(Math.max(1, Math.floor(page || 1))); }
setPageSize(size: number) { this.pageSize$.next(Math.min(50, Math.max(1, Math.floor(size || 24)))); }
setSort(sort: 'relevance' | 'date' | 'views' | 'duration') { this.sort$.next(sort || 'relevance'); }
} }

View File

@ -66,7 +66,7 @@
<ul *ngIf="!isLoading() && filteredSearchHistory().length > 0" class="space-y-3"> <ul *ngIf="!isLoading() && filteredSearchHistory().length > 0" class="space-y-3">
<li *ngFor="let s of filteredSearchHistory()" <li *ngFor="let s of filteredSearchHistory()"
class="group relative bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200"> class="group relative bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
<a [routerLink]="['/search']" [queryParams]="{ q: s.query, provider: getSearchProvider(s).id }" <a [routerLink]="['/search']" [queryParams]="getSearchProvider(s).providers ? { q: s.query, providers: getSearchProvider(s).providers } : { q: s.query, provider: getSearchProvider(s).id }"
class="block p-4 pr-16 hover:no-underline"> class="block p-4 pr-16 hover:no-underline">
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<span class="text-slate-400 mt-0.5"> <span class="text-slate-400 mt-0.5">
@ -80,15 +80,7 @@
</div> </div>
<div class="flex items-center mt-1 text-xs text-slate-400"> <div class="flex items-center mt-1 text-xs text-slate-400">
<span class="inline-flex items-center px-2 py-0.5 rounded mr-2 border" <span class="inline-flex items-center px-2 py-0.5 rounded mr-2 border"
[ngStyle]="getProviderColors(getSearchProvider(s).id)" [ngStyle]="getProviderColors(getSearchProvider(s).id)">
[ngClass]="{
'bg-red-600/15 text-red-400 border-red-500/30': getSearchProvider(s).id === 'youtube',
'bg-sky-600/15 text-sky-300 border-sky-500/30': getSearchProvider(s).id === 'vimeo',
'bg-blue-600/15 text-blue-300 border-blue-500/30': getSearchProvider(s).id === 'dailymotion',
'bg-amber-600/15 text-amber-300 border-amber-500/30': getSearchProvider(s).id === 'peertube',
'bg-green-600/15 text-green-300 border-green-500/30': getSearchProvider(s).id === 'rumble',
'bg-slate-800/80 text-slate-300 border-slate-700': !['youtube', 'vimeo', 'dailymotion', 'peertube', 'rumble'].includes(getSearchProvider(s).id)
}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /> <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> </svg>
@ -122,7 +114,7 @@
<ul *ngIf="searchHistory().length > 0" class="space-y-3"> <ul *ngIf="searchHistory().length > 0" class="space-y-3">
<li *ngFor="let s of searchHistory()" <li *ngFor="let s of searchHistory()"
class="group relative bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200"> class="group relative bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
<a [routerLink]="['/search']" [queryParams]="{ q: s.query, provider: getSearchProvider(s).id }" <a [routerLink]="['/search']" [queryParams]="getSearchProvider(s).providers ? { q: s.query, providers: getSearchProvider(s).providers } : { q: s.query, provider: getSearchProvider(s).id }"
class="block p-4 pr-16 hover:no-underline"> class="block p-4 pr-16 hover:no-underline">
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<span class="text-slate-400 mt-0.5"> <span class="text-slate-400 mt-0.5">
@ -136,15 +128,7 @@
</div> </div>
<div class="flex items-center mt-1 text-xs text-slate-400"> <div class="flex items-center mt-1 text-xs text-slate-400">
<span class="inline-flex items-center px-2 py-0.5 rounded mr-2 border" <span class="inline-flex items-center px-2 py-0.5 rounded mr-2 border"
[ngStyle]="getProviderColors(getSearchProvider(s).id)" [ngStyle]="getProviderColors(getSearchProvider(s).id)">
[ngClass]="{
'bg-red-600/15 text-red-400 border-red-500/30': getSearchProvider(s).id === 'youtube',
'bg-sky-600/15 text-sky-300 border-sky-500/30': getSearchProvider(s).id === 'vimeo',
'bg-blue-600/15 text-blue-300 border-blue-500/30': getSearchProvider(s).id === 'dailymotion',
'bg-amber-600/15 text-amber-300 border-amber-500/30': getSearchProvider(s).id === 'peertube',
'bg-green-600/15 text-green-300 border-green-500/30': getSearchProvider(s).id === 'rumble',
'bg-slate-800/80 text-slate-300 border-slate-700': !['youtube', 'vimeo', 'dailymotion', 'peertube', 'rumble'].includes(getSearchProvider(s).id)
}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /> <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> </svg>

View File

@ -1,5 +1,4 @@
import { ChangeDetectionStrategy, Component, inject, signal, computed, effect } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject, signal, computed, effect } from '@angular/core';
import { NgClass } from '@angular/common';
import { CommonModule, DatePipe } from '@angular/common'; import { CommonModule, DatePipe } from '@angular/common';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@ -12,7 +11,7 @@ import { HistoryService, SearchHistoryItem, WatchHistoryItem } from '../../../se
standalone: true, standalone: true,
templateUrl: './history.component.html', templateUrl: './history.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterModule, FormsModule, NgClass] imports: [CommonModule, RouterModule, FormsModule]
}) })
export class HistoryComponent { export class HistoryComponent {
private history = inject(HistoryService); private history = inject(HistoryService);
@ -32,6 +31,20 @@ export class HistoryComponent {
// Sujet pour la recherche avec debounce // Sujet pour la recherche avec debounce
private searchTerms = new Subject<string>(); private searchTerms = new Subject<string>();
// Obtenir le nom d'affichage d'un provider
getProviderName(providerId: string): string {
const names: Record<string, string> = {
'youtube': 'YouTube',
'dailymotion': 'Dailymotion',
'twitch': 'Twitch',
'peertube': 'PeerTube',
'odysee': 'Odysee',
'rumble': 'Rumble'
};
return names[providerId.toLowerCase()] || providerId;
}
constructor() { constructor() {
// Chargement initial des données // Chargement initial des données
this.reload(); this.reload();
@ -151,13 +164,25 @@ export class HistoryComponent {
}); });
} }
// Get provider from search filters or query params getSearchProvider(item: SearchHistoryItem): { name: string; id: string; providers?: string[] } {
getSearchProvider(item: SearchHistoryItem): { name: string; id: string } { // Try to get providers from filters first
// Try to get provider from filters first
if (item.filters_json) { if (item.filters_json) {
try { try {
const filters = JSON.parse(item.filters_json); const filters = JSON.parse(item.filters_json);
if (filters.provider) { if (filters.providers) {
// Multiple providers available
const providers = Array.isArray(filters.providers) ? filters.providers : [filters.providers];
if (providers.length === 1) {
const providerName = this.formatProviderName(providers[0]);
return { name: providerName, id: providers[0].toLowerCase() };
} else {
const firstProviderName = this.formatProviderName(providers[0]);
const othersCount = providers.length - 1;
const name = `${firstProviderName} + ${othersCount} autre${othersCount > 1 ? 's' : ''}`;
return { name: name, id: providers[0], providers: providers };
}
} else if (filters.provider) {
// Single provider
const providerName = this.formatProviderName(filters.provider); const providerName = this.formatProviderName(filters.provider);
return { name: providerName, id: String(filters.provider).toLowerCase() }; return { name: providerName, id: String(filters.provider).toLowerCase() };
} }
@ -178,12 +203,6 @@ export class HistoryComponent {
// Not a valid URL or no provider in query params // Not a valid URL or no provider in query params
} }
// Default to the selected provider if available
const defaultProvider = this.getDefaultProvider();
if (defaultProvider) {
return { name: this.formatProviderName(defaultProvider), id: defaultProvider.toLowerCase() };
}
// Fallback to 'all' if no provider can be determined // Fallback to 'all' if no provider can be determined
return { name: 'Tous', id: 'all' }; return { name: 'Tous', id: 'all' };
} }
@ -207,6 +226,10 @@ export class HistoryComponent {
} }
} }
getProvidersWithInfo(item: SearchHistoryItem) {
return [];
}
// Format provider ID to a display name // Format provider ID to a display name
private formatProviderName(providerId: string): string { private formatProviderName(providerId: string): string {
const providerMap: { [key: string]: string } = { const providerMap: { [key: string]: string } = {
@ -225,27 +248,66 @@ export class HistoryComponent {
return this.formatProviderName(providerId); return this.formatProviderName(providerId);
} }
// Brand colors for provider badges (inline to avoid Tailwind purge issues)
getProviderColors(providerId: string): { [key: string]: string } { // Obtenir les styles pour un provider
const id = (providerId || '').toLowerCase(); getProviderColors(providerId: string | null | undefined): { [key: string]: string } {
if (!providerId) {
return {
'background-color': 'rgb(51 65 85 / 0.15)',
'color': 'rgb(148 163 184)',
'border-color': 'rgb(71 85 105 / 0.3)'
};
}
const id = providerId.toLowerCase();
let bgColor = 'rgb(51 65 85 / 0.15)';
let textColor = 'rgb(148 163 184)';
let borderColor = 'rgb(71 85 105 / 0.3)';
switch (id) { switch (id) {
case 'youtube': case 'youtube':
return { backgroundColor: 'rgba(220, 38, 38, 0.15)', color: 'rgb(248, 113, 113)', borderColor: 'rgba(239, 68, 68, 0.3)' }; case 'yt':
case 'vimeo': bgColor = 'rgb(220 38 38 / 0.15)';
return { backgroundColor: 'rgba(2, 132, 199, 0.15)', color: 'rgb(125, 211, 252)', borderColor: 'rgba(14, 165, 233, 0.3)' }; textColor = 'rgb(248 113 113)';
borderColor = 'rgb(239 68 68 / 0.3)';
break;
case 'dailymotion': case 'dailymotion':
return { backgroundColor: 'rgba(37, 99, 235, 0.15)', color: 'rgb(147, 197, 253)', borderColor: 'rgba(59, 130, 246, 0.3)' }; case 'dm':
case 'peertube': bgColor = 'rgb(37 99 235 / 0.15)';
return { backgroundColor: 'rgba(245, 158, 11, 0.15)', color: 'rgb(252, 211, 77)', borderColor: 'rgba(245, 158, 11, 0.3)' }; textColor = 'rgb(96 165 250)';
case 'rumble': borderColor = 'rgb(59 130 246 / 0.3)';
return { backgroundColor: 'rgba(22, 163, 74, 0.15)', color: 'rgb(134, 239, 172)', borderColor: 'rgba(34, 197, 94, 0.3)' }; break;
case 'twitch': case 'twitch':
return { backgroundColor: 'rgba(168, 85, 247, 0.15)', color: 'rgb(216, 180, 254)', borderColor: 'rgba(168, 85, 247, 0.3)' }; case 'tw':
bgColor = 'rgb(147 51 234 / 0.15)';
textColor = 'rgb(192 132 252)';
borderColor = 'rgb(168 85 247 / 0.3)';
break;
case 'peertube':
case 'pt':
bgColor = 'rgb(217 119 6 / 0.15)';
textColor = 'rgb(251 191 36)';
borderColor = 'rgb(245 158 11 / 0.3)';
break;
case 'odysee': case 'odysee':
return { backgroundColor: 'rgba(236, 72, 153, 0.15)', color: 'rgb(251, 207, 232)', borderColor: 'rgba(236, 72, 153, 0.3)' }; case 'od':
default: bgColor = 'rgb(22 163 74 / 0.15)';
return { backgroundColor: 'rgba(30, 41, 59, 0.8)', color: 'rgb(203, 213, 225)', borderColor: 'rgb(51, 65, 85)' }; textColor = 'rgb(74 222 128)';
borderColor = 'rgb(34 197 94 / 0.3)';
break;
case 'rumble':
case 'ru':
bgColor = 'rgb(234 88 12 / 0.15)';
textColor = 'rgb(251 146 60)';
borderColor = 'rgb(249 115 22 / 0.3)';
break;
} }
return {
'background-color': bgColor,
'color': textColor,
'border-color': borderColor
};
} }
// Format seconds to HH:MM:SS or MM:SS // Format seconds to HH:MM:SS or MM:SS
@ -255,7 +317,6 @@ export class HistoryComponent {
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60); const secs = Math.floor(seconds % 60);
if (hours > 0) { if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
} else { } else {

View File

@ -102,6 +102,8 @@ export class HeaderComponent {
qp.providers = 'yt,dm,tw,pt,od,ru'; qp.providers = 'yt,dm,tw,pt,od,ru';
} }
if (theme) qp.theme = theme; if (theme) qp.theme = theme;
try { console.debug('[Header] onSearchBoxSubmit -> navigate', qp); } catch {}
this.suggestionsOpen.set(false);
this.router.navigate(['/search'], { queryParams: qp }); this.router.navigate(['/search'], { queryParams: qp });
} }

View File

@ -1,4 +1,4 @@
<form class="w-full relative" (submit)="onSubmit($event)" (keydown)="handleKeydown($event)"> <form class="w-full relative" novalidate (ngSubmit)="onSubmit($event)" (submit)="onSubmit($event)" (keydown)="handleKeydown($event)" role="search" aria-label="Recherche">
<!-- Input --> <!-- Input -->
<div class="flex items-center gap-2 rounded-lg ring-1 ring-slate-600/50 bg-slate-800/70 px-3 py-2"> <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 --> <!-- Provider chips -->
@ -25,14 +25,20 @@
</ng-container> </ng-container>
<!-- Text input --> <!-- Text input -->
<input type="search" <input #qInput type="search"
class="flex-1 bg-transparent outline-none text-slate-100 placeholder-slate-400 px-2" class="flex-1 bg-transparent outline-none text-slate-100 placeholder-slate-400 px-2"
[placeholder]="placeholder" [placeholder]="placeholder"
[ngModel]="query()" (ngModelChange)="queryUpdate($event)" (keydown)="handleKeydown($event)" name="q"/> [ngModel]="query()" (ngModelChange)="queryUpdate($event)" (keydown)="handleKeydown($event)" (keydown.enter)="onSubmit($event)" name="q"
autocomplete="off" aria-label="Rechercher des vidéos"/>
<!-- Actions --> <!-- Actions -->
<button type="button" (click)="openPicker()" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600">@</button> <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">Search</button> <button *ngIf="(query() || '').length > 0" type="button" (click)="queryUpdate('')" class="text-xs px-2 py-1 rounded hover:bg-slate-700/70" aria-label="Effacer la recherche">×</button>
<button type="submit" class="ml-1 px-3 py-1 rounded bg-red-600 hover:bg-red-500 text-white"
(click)="onSubmit($event)"
[attr.title]="(query() || '').trim().length < 2 ? 'Tapez au moins 2 caractères' : null">
Search
</button>
</div> </div>
<!-- Provider Picker Modal --> <!-- Provider Picker Modal -->

View File

@ -1,27 +1,44 @@
import { Component, EventEmitter, Output, Input, signal, computed, effect, inject, HostListener } from '@angular/core'; import { Component, EventEmitter, Output, Input, signal, computed, effect, inject, HostListener, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { ProviderId, PROVIDERS } from '../../app/core/providers/provider-registry'; import { ProviderId, PROVIDERS } from '../../app/core/providers/provider-registry';
import { ProviderPickerComponent } from './provider-picker.component'; import { ProviderPickerComponent } from './provider-picker.component';
import { SearchService } from '../../app/search/search.service'; import { SearchService } from '../../app/search/search.service';
import { UserService, type UserPreferences } from '../../services/user.service'; import { UserService, type UserPreferences } from '../../services/user.service';
import { HistoryService, type SearchHistoryItem } from '../../services/history.service'; import { HistoryService, type SearchHistoryItem } from '../../services/history.service';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
// No overlays; we render fixed-position modals with high z-index // No overlays; we render fixed-position modals with high z-index
@Component({ @Component({
selector: 'app-search-box', selector: 'app-search-box',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, ProviderPickerComponent], imports: [
CommonModule,
FormsModule,
ProviderPickerComponent,
HttpClientModule // Nécessaire pour SearchService
],
providers: [
SearchService, // Fournir le service au niveau du composant
UserService, // S'assurer que UserService est fourni
HistoryService // S'assurer que HistoryService est fourni
],
templateUrl: './search-box.component.html' templateUrl: './search-box.component.html'
}) })
export class SearchBoxComponent { export class SearchBoxComponent {
private search = inject(SearchService); private search = inject(SearchService);
private users = inject(UserService); private users = inject(UserService);
private history = inject(HistoryService); private history = inject(HistoryService);
@ViewChild('qInput', { static: false }) qInputRef?: ElementRef<HTMLInputElement>;
// Input/Output bindings // Input/Output bindings
@Input() placeholder: string = 'Search videos…'; @Input() placeholder: string = 'Search videos…';
@Output() submitted = new EventEmitter<{ q: string; providers: ProviderId[] | 'all' }>(); @Output() submitted = new EventEmitter<{ q: string; providers: ProviderId[] | 'all' }>();
// Emits debounced query changes for autosuggest
@Output() searchChange = new EventEmitter<string>();
// Local state signals // Local state signals
query = signal(''); query = signal('');
@ -56,11 +73,23 @@ export class SearchBoxComponent {
}); });
constructor() { constructor() {
// Flag to avoid clobbering user input after initial hydration from SearchService
let hydratedFromService = false;
// Debounced query change emitter for suggestions/autocomplete
this._qChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
takeUntilDestroyed()
).subscribe((value) => {
this.searchChange.emit((value || '').trim());
});
// Reflect SearchService into local state to restore from URL/preference // Reflect SearchService into local state to restore from URL/preference
effect(() => { effect(() => {
if (hydratedFromService) return; // only hydrate once
const q = this.search.q$.value; const q = this.search.q$.value;
const prov = this.search.providers$.value; const prov = this.search.providers$.value;
if (typeof q === 'string' && q !== this.query()) this.query.set(q); if (typeof q === 'string') this.query.set(q);
if (prov === 'all') { if (prov === 'all') {
this.providerMode.set('all'); this.providerMode.set('all');
this.selected.set([]); this.selected.set([]);
@ -68,7 +97,7 @@ export class SearchBoxComponent {
this.providerMode.set('custom'); this.providerMode.set('custom');
this.selected.set(prov); this.selected.set(prov);
} }
hydratedFromService = true;
}); });
// Apply default providers from user preferences if none explicitly set // Apply default providers from user preferences if none explicitly set
@ -89,7 +118,11 @@ export class SearchBoxComponent {
// Update query and handle inline '@provider' prefix to open filtered picker // Update query and handle inline '@provider' prefix to open filtered picker
queryUpdate(value: string) { queryUpdate(value: string) {
try { console.log('[SearchBox] queryUpdate', { value }); } catch {}
this.query.set(value); this.query.set(value);
// Keep SearchService in sync with current text to avoid stale overwrite
try { this.search.q$.next(value); } catch {}
this._qChanges.next(value);
const m = /(^|\s)@([a-z]{1,12})$/i.exec(value); const m = /(^|\s)@([a-z]{1,12})$/i.exec(value);
if (m) { if (m) {
const term = m[2] || ''; const term = m[2] || '';
@ -153,8 +186,14 @@ export class SearchBoxComponent {
if (pid) { ev.preventDefault(); this.toggleChip(pid); } if (pid) { ev.preventDefault(); this.toggleChip(pid); }
} }
if (ev.key === 'Escape') { if (ev.key === 'Escape') {
// Escape clears transient UI; if nothing is open, also clear the query
const hadAny = this.pickerOpen() || this.suggestionsOpen() || this.atOpen() || this.quickOpen();
this.pickerOpen.set(false); this.pickerOpen.set(false);
this.suggestionsOpen.set(false); this.suggestionsOpen.set(false);
this.atOpen.set(false);
if (!hadAny && (this.query() || '').length > 0) {
this.queryUpdate('');
}
} }
} }
@ -189,14 +228,37 @@ export class SearchBoxComponent {
} }
onSubmit(ev: Event) { onSubmit(ev: Event) {
ev.preventDefault(); try { ev.preventDefault(); } catch {}
const q = (this.query() || '').trim(); let q = (this.query() || '').trim();
if (!q) return; if ((!q || q.length === 0) && this.qInputRef?.nativeElement) {
try {
const raw = this.qInputRef.nativeElement.value || '';
q = String(raw).trim();
console.log('[SearchBox] fallback native value used', { q, qLength: q.length });
} catch {}
}
// Debug: minimal console trace to confirm handler runs
try {
console.log('[SearchBox] onSubmit called', {
q,
qLength: q.length,
hasParentHandler: !!this.submitted.observers.length
});
} catch(e) {
console.error('Error in SearchBox onSubmit:', e);
}
if (q.length < 2) {
console.log('[SearchBox] Query too short, not submitting');
return;
}
const prov = this.providersForRequest(); const prov = this.providersForRequest();
// Update service for global listeners console.log('[SearchBox] Emitting submit event', { q, providers: prov });
this.search.setQuery(q);
this.search.setProviders(prov); // Emit payload to parent (Header/Search page)
this.submitted.emit({ q, providers: prov }); this.submitted.emit({ q, providers: prov });
console.log('[SearchBox] Submit event emitted');
} }
openPicker() { this.pickerOpen.set(true); } openPicker() { this.pickerOpen.set(true); }
@ -244,4 +306,7 @@ export class SearchBoxComponent {
document.body.style.overflow = open ? 'hidden' : ''; document.body.style.overflow = open ? 'hidden' : '';
} catch {} } catch {}
}); });
// Internal subject for debouncing query changes
private _qChanges = new Subject<string>();
} }

View File

@ -1,5 +1,8 @@
<div class="container mx-auto p-4 sm:p-6"> <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">{{ pageHeading() }}</h2> <header class="mb-4">
<h1 class="text-xl sm:text-2xl font-semibold text-slate-100">{{ pageHeading() }}</h1>
<p class="text-sm text-white/60 mt-1">{{ activeProvidersDisplay() }}</p>
</header>
@if (notice()) { @if (notice()) {
<div class="mb-4 bg-amber-900/40 border border-amber-600 text-amber-200 p-3 rounded"> <div class="mb-4 bg-amber-900/40 border border-amber-600 text-amber-200 p-3 rounded">
@ -27,21 +30,68 @@
</div> </div>
} }
<!-- Unified multi-provider grouped suggestions --> <!-- Unified per-provider sections -->
@if (showUnified()) { @if (showUnified()) {
<app-search-suggestions [groups]="groups()" [query]="q()"></app-search-suggestions> <section *ngFor="let p of activeProviders()">
<div class="sticky top-0 z-10 flex items-center justify-between bg-black/50 backdrop-blur px-4 py-2 border-b border-white/10">
<h2 class="text-sm font-medium">{{ providerDisplay(p) }} <span class="text-white/40" *ngIf="groups()[p]">({{ groups()[p].length || 0 }})</span></h2>
<button class="inline-flex items-center rounded-xl px-3 py-1.5 text-xs border border-white/20 hover:bg-white/5"
(click)="onViewAll(p)"
aria-label="Voir tout sur {{ providerDisplay(p) }}">
Voir tout sur {{ providerDisplay(p) }}
</button>
</div>
<div *ngIf="groups()[p]?.length; else noResTpl" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 p-4">
<a *ngFor="let v of groups()[p]"
class="group block rounded-2xl border border-white/10 bg-white/5 hover:bg-white/10 transition overflow-hidden"
[href]="v.url || '#'" target="_blank" rel="noopener noreferrer"
[attr.aria-label]="v.title">
<div class="relative">
<img [src]="v.thumbnail" [alt]="v.title" class="w-full h-44 object-cover" loading="lazy">
</div>
<div class="p-3">
<div class="text-sm font-medium text-slate-100 line-clamp-2">{{ v.title }}</div>
<div class="mt-1 text-xs text-white/60 line-clamp-1">{{ v.uploaderName }}</div>
</div>
</a>
</div>
<ng-template #noResTpl>
<div class="px-4 py-6 text-white/50 text-sm">Aucun résultat pour ce fournisseur.</div>
</ng-template>
</section>
} }
<input @if (hasQuery()) {
#searchInput <div class="my-4 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
type="text" <div class="flex items-center gap-2">
class="flex-1 bg-slate-900 text-slate-100 border border-slate-700 rounded-l-full px-4 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" <label class="text-sm text-slate-300" for="sortSelect">Trier par</label>
placeholder="Rechercher..." <select id="sortSelect" class="bg-slate-900 text-slate-100 border border-slate-700 rounded px-2 py-1 text-sm"
[value]="q()" [value]="sortParam()" (change)="onSortChange($event)">
(input)="onSearchInput($event)" <option value="relevance">Pertinence</option>
(keydown.enter)="onSearchEnter($event)" <option value="date">Date</option>
(keydown.escape)="closeSearchResults()" <option value="views">Vues</option>
/> </select>
</div>
<div class="flex items-center gap-2 justify-end">
<button class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 disabled:opacity-50"
(click)="prevPage()" [disabled]="!canPrev()">Précédent</button>
<div class="text-sm text-slate-300">Page {{ pageParam() }}</div>
<button class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" (click)="nextPage()">Suivant</button>
</div>
</div>
}
@if (showUnified() && !loading() && unifiedTotal() === 0 && hasQuery()) {
<p class="text-slate-400">Aucun résultat pour « {{ q() }} » avec les fournisseurs sélectionnés.</p>
}
@if (error()) {
<div class="mb-4 bg-red-900/40 border border-red-600 text-red-200 p-3 rounded flex items-center justify-between">
<span>{{ error() }}</span>
<button class="px-3 py-1 rounded bg-red-600 hover:bg-red-500 text-white text-sm" (click)="onSearchBarSubmit({ q: q(), providers: providersListParam() || 'all' })">Réessayer</button>
</div>
}
@if (loading()) { @if (loading()) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"> <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">

View File

@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, signal, untracked, ViewChild } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, signal, untracked, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { YoutubeApiService } from '../../services/youtube-api.service'; import { YoutubeApiService } from '../../services/youtube-api.service';
import { Video } from '../../models/video.model'; import { Video } from '../../models/video.model';
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component'; import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
@ -11,19 +11,20 @@ import { Title } from '@angular/platform-browser';
import { TranslatePipe } from '../../pipes/translate.pipe'; import { TranslatePipe } from '../../pipes/translate.pipe';
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component'; import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
import { AddToPlaylistComponent } from '../shared/components/add-to-playlist/add-to-playlist.component'; import { AddToPlaylistComponent } from '../shared/components/add-to-playlist/add-to-playlist.component';
import { SearchSuggestionsComponent } from './search-suggestions.component';
import { SearchService, type SearchResponse } from '../../app/search/search.service'; import { SearchService, type SearchResponse } from '../../app/search/search.service';
import type { ProviderId } from '../../app/core/providers/provider-registry'; import type { ProviderId } from '../../app/core/providers/provider-registry';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({ @Component({
selector: 'app-search', selector: 'app-search',
standalone: true, standalone: true,
templateUrl: './search.component.html', templateUrl: './search.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent, AddToPlaylistComponent, SearchSuggestionsComponent] imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent, AddToPlaylistComponent]
}) })
export class SearchComponent { export class SearchComponent {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router);
private api = inject(YoutubeApiService); private api = inject(YoutubeApiService);
private instances = inject(InstanceService); private instances = inject(InstanceService);
private history = inject(HistoryService); private history = inject(HistoryService);
@ -46,10 +47,16 @@ export class SearchComponent {
providerParam = signal<Provider | null>(null); providerParam = signal<Provider | null>(null);
themeParam = signal<string | null>(null); themeParam = signal<string | null>(null);
providersListParam = signal<ProviderId[] | 'all' | null>(null); providersListParam = signal<ProviderId[] | 'all' | null>(null);
pageParam = signal<number>(1);
sortParam = signal<'relevance' | 'date' | 'views'>('relevance');
// Unified multi-provider response (grouped suggestions) // Unified multi-provider response (grouped suggestions)
groups = signal<Record<ProviderId, any[]>>({} as any); groups = signal<Record<ProviderId, any[]>>({} as any);
showUnified = signal<boolean>(false); showUnified = signal<boolean>(false);
error = signal<string | null>(null);
// Dedup key to avoid recording the same search multiple times
private lastRecordedKey: string | null = null;
hasQuery = computed(() => this.q().length > 0); hasQuery = computed(() => this.q().length > 0);
providerLabel = computed(() => { providerLabel = computed(() => {
@ -60,7 +67,33 @@ export class SearchComponent {
} }
return this.instances.selectedProviderLabel(); return this.instances.selectedProviderLabel();
}); });
pageHeading = computed(() => `Search - ${this.providerLabel()}${this.q() ? ' - ' + this.q() : ''}`);
// Active providers derived from `providers` query param or fallback to all available
activeProviders = computed<ProviderId[]>(() => {
const p = this.providersListParam();
if (p === 'all' || p == null) {
try { return (this.instances.providers() || []).map(x => x.id as unknown as ProviderId); }
catch { return ['yt','dm','tw','pt','od','ru']; }
}
if (Array.isArray(p) && p.length > 0) return p as ProviderId[];
return ['yt','dm','tw','pt','od','ru'];
});
// Subtitle display: codes separated by middot
activeProvidersDisplay = computed(() => this.activeProviders().join(' · '));
// Helper to display provider label from registry/instances
providerDisplay(p: ProviderId): string {
try {
const found = (this.instances.providers() || []).find(x => (x.id as any) === p);
return found?.label || p.toUpperCase();
} catch { return p.toUpperCase(); }
}
// Title: Search ${q ? ` — “${q}”` : ''}
pageHeading = computed(() => {
const q = this.q();
return `Search ${q ? ` — “${q}` : ''}`;
});
// Public computed used by the template to avoid referencing private `instances` // Public computed used by the template to avoid referencing private `instances`
selectedProviderForView = computed(() => this.providerParam() || this.instances.selectedProvider()); selectedProviderForView = computed(() => this.providerParam() || this.instances.selectedProvider());
@ -68,6 +101,16 @@ export class SearchComponent {
// For Twitch provider: 'twitch_all' | 'twitch_live' | 'twitch_vod'. // For Twitch provider: 'twitch_all' | 'twitch_live' | 'twitch_vod'.
filterTag = signal<string>('all'); filterTag = signal<string>('all');
// Unified total results count (sum of groups lengths)
unifiedTotal = computed(() => {
const g = this.groups();
try {
return Object.values(g).reduce((acc: number, arr: any) => acc + (Array.isArray(arr) ? arr.length : 0), 0);
} catch { return 0; }
});
canPrev = computed(() => (this.pageParam() || 1) > 1);
// Helper computed: filtered list based on active tag (non-Twitch only) // Helper computed: filtered list based on active tag (non-Twitch only)
filteredResults = computed(() => { filteredResults = computed(() => {
const tag = this.filterTag(); const tag = this.filterTag();
@ -247,24 +290,29 @@ export class SearchComponent {
}); });
// Subscribe once to unified multi-provider results and reflect to UI // Subscribe once to unified multi-provider results and reflect to UI
this.unified.request$.subscribe({ this.unified.request$.pipe(takeUntilDestroyed()).subscribe({
next: (resp: SearchResponse) => { next: (resp: SearchResponse) => {
this.groups.set(resp.groups as any); this.groups.set(resp.groups as any);
this.loading.set(false); this.loading.set(false);
this.error.set(null);
}, },
error: () => { error: () => {
this.groups.set({} as any); this.groups.set({} as any);
this.loading.set(false); this.loading.set(false);
this.error.set('Le service de recherche est temporairement indisponible. Réessayer.');
} }
}); });
// Listen to query param changes (so subsequent searches update) // Listen to query param changes (so subsequent searches update)
this.route.queryParamMap.subscribe((pm) => { this.route.queryParamMap.pipe(takeUntilDestroyed()).subscribe((pm) => {
const q = (pm.get('q') || '').trim(); const q = (pm.get('q') || '').trim();
this.q.set(q); this.q.set(q);
const prov = (pm.get('provider') as Provider) || null; const prov = (pm.get('provider') as Provider) || (pm.get('p') as Provider) || null;
const theme = pm.get('theme'); const theme = pm.get('theme');
const providersParam = (pm.get('providers') || '').trim(); const providersParam = (pm.get('providers') || '').trim();
const page = Number(pm.get('page') || '1');
const pageSize = Number(pm.get('pageSize') || '24');
const sort = pm.get('sort') || undefined;
// Parse providers CSV if present // Parse providers CSV if present
let providersList: ProviderId[] | 'all' | null = null; let providersList: ProviderId[] | 'all' | null = null;
if (providersParam) { if (providersParam) {
@ -274,6 +322,13 @@ export class SearchComponent {
this.providerParam.set(prov); this.providerParam.set(prov);
this.themeParam.set(theme); this.themeParam.set(theme);
this.providersListParam.set(providersList); this.providersListParam.set(providersList);
const pageNum = isFinite(page) ? page : 1;
this.pageParam.set(pageNum);
this.unified.setPage(pageNum);
if (isFinite(pageSize)) this.unified.setPageSize(pageSize);
const sortNorm = (sort === 'date' || sort === 'views') ? sort : 'relevance';
this.sortParam.set(sortNorm);
this.unified.setSort(sortNorm);
if (q) { if (q) {
this.notice.set(null); this.notice.set(null);
@ -285,15 +340,33 @@ export class SearchComponent {
const providersToUse = providersList || [provider as ProviderId]; const providersToUse = providersList || [provider as ProviderId];
this.unified.setQuery(q); this.unified.setQuery(q);
this.unified.setProviders(providersToUse); this.unified.setProviders(providersToUse);
// Record this search once per unique (q, providers) combination
try {
const key = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}`;
if (this.lastRecordedKey !== key) {
this.lastRecordedKey = key;
const recFilters: any = { provider, providers: Array.isArray(providersToUse) ? providersToUse : 'all' };
this.history.recordSearch(q, recFilters).subscribe({
next: () => {},
error: (err) => console.error('Error recording search:', err)
});
}
} catch (err) {
console.error('Error in recordSearch:', err);
}
this.showUnified.set(true); this.showUnified.set(true);
this.loading.set(true); this.loading.set(true);
this.groups.set({} as any); // Clear previous results this.groups.set({} as any); // Clear previous results
this.error.set(null);
} else { } else {
this.results.set([]); this.results.set([]);
this.nextCursor.set(null); this.nextCursor.set(null);
this.loading.set(false); this.loading.set(false);
this.groups.set({} as any); this.groups.set({} as any);
this.showUnified.set(false); this.showUnified.set(false);
this.error.set(null);
} }
}); });
@ -464,30 +537,21 @@ export class SearchComponent {
return qp; return qp;
} }
// Submit handler from the embedded SearchBox: keep URL as source of truth
onSearchBarSubmit(evt: { q: string; providers: ProviderId[] | 'all' }) {
const q = (evt?.q || '').trim();
if (!q) return;
const provider = this.providerParam() || this.instances.selectedProvider();
const qp: any = { q, p: provider, provider: null };
if (Array.isArray(evt.providers)) qp.providers = evt.providers.join(',');
else if (evt.providers === 'all') qp.providers = 'yt,dm,tw,pt,od,ru';
qp.page = 1; // reset pagination on new search
this.router.navigate([], { relativeTo: this.route, queryParams: qp, queryParamsHandling: 'merge' });
}
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>; @ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
onSearchEnter(event: Event) { // Legacy input handlers removed; SearchBox handles keyboard and submit
const keyboardEvent = event as KeyboardEvent;
keyboardEvent.preventDefault();
const query = this.q().trim();
if (query) {
// Route unified search regardless of providers param
const provParam = this.providersListParam();
const provider = this.providerParam() || this.instances.selectedProvider();
const providersToUse = (provParam && (provParam === 'all' || (Array.isArray(provParam) && provParam.length > 0)))
? (provParam as any)
: [provider as unknown as ProviderId];
this.unified.setQuery(query);
this.unified.setProviders(providersToUse);
this.showUnified.set(true);
this.loading.set(true);
this.closeSearchResults();
// Retirer le focus de l'input
if (this.searchInput) {
this.searchInput.nativeElement.blur();
}
}
}
closeSearchResults() { closeSearchResults() {
// Cette méthode sera appelée pour fermer les résultats de recherche // Cette méthode sera appelée pour fermer les résultats de recherche
@ -506,4 +570,29 @@ export class SearchComponent {
setFilterTag(key: string) { setFilterTag(key: string) {
this.filterTag.set(key); this.filterTag.set(key);
} }
// Pagination controls
nextPage() {
const next = (this.pageParam() || 1) + 1;
this.router.navigate([], { relativeTo: this.route, queryParams: { page: next }, queryParamsHandling: 'merge' });
}
prevPage() {
const prev = Math.max(1, (this.pageParam() || 1) - 1);
this.router.navigate([], { relativeTo: this.route, queryParams: { page: prev }, queryParamsHandling: 'merge' });
}
// Sort control
onSortChange(event: Event) {
const value = (event.target as HTMLSelectElement)?.value as 'relevance' | 'date' | 'views' | '';
const sort = (value === 'date' || value === 'views') ? value : 'relevance';
this.router.navigate([], { relativeTo: this.route, queryParams: { sort, page: 1 }, queryParamsHandling: 'merge' });
}
// CTA handler: navigate to focus on a single provider while preserving q
onViewAll(p: ProviderId) {
const q = (this.q() || '').trim();
const qp: any = { provider: p, p, providers: p, page: 1 };
if (q) qp.q = q;
this.router.navigate([], { relativeTo: this.route, queryParams: qp, queryParamsHandling: 'merge' });
}
} }

View File

@ -7,6 +7,7 @@ export interface SearchHistoryItem {
id: string; id: string;
query: string; query: string;
filters_json?: string | null; filters_json?: string | null;
providers?: string[]; // Liste des providers utilisés (ex: ['yt', 'dm', 'tw'])
created_at: string; created_at: string;
} }
@ -39,10 +40,16 @@ export class HistoryService {
// --- Search --- // --- Search ---
recordSearch(query: string, filters?: Record<string, any>): Observable<{ id: string; created_at: string }> { recordSearch(query: string, filters?: Record<string, any>): Observable<{ id: string; created_at: string }> {
const url = `${this.apiBase()}/user/history/search`;
return this.http.post<{ id: string; created_at: string }>( return this.http.post<{ id: string; created_at: string }>(
'/proxy/api/user/history/search', url,
{ query, filters }, { query, filters },
{ withCredentials: true } { withCredentials: true }
).pipe(
tap({
next: (res) => console.debug('[HistoryService] Search recorded', { query, filters, res }),
error: (err) => console.error('[HistoryService] Failed to record search', err)
})
); );
} }
@ -50,13 +57,13 @@ export class HistoryService {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('limit', String(limit)); params.set('limit', String(limit));
if (before) params.set('before', before); if (before) params.set('before', before);
return this.http.get<SearchHistoryItem[]>(`/proxy/api/user/history/search?${params.toString()}`, { withCredentials: true }); return this.http.get<SearchHistoryItem[]>(`${this.apiBase()}/user/history/search?${params.toString()}`, { withCredentials: true });
} }
// --- Watch --- // --- Watch ---
recordWatchStart(provider: string, videoId: string, title?: string | null, watchedAt?: string, thumbnail?: string | null, durationSeconds?: number): Observable<WatchHistoryItem> { recordWatchStart(provider: string, videoId: string, title?: string | null, watchedAt?: string, thumbnail?: string | null, durationSeconds?: number): Observable<WatchHistoryItem> {
return this.http.post<WatchHistoryItem>( return this.http.post<WatchHistoryItem>(
'/proxy/api/user/history/watch', `${this.apiBase()}/user/history/watch`,
{ provider, videoId, title: title ?? null, watchedAt, thumbnail: thumbnail ?? null, durationSeconds }, { provider, videoId, title: title ?? null, watchedAt, thumbnail: thumbnail ?? null, durationSeconds },
{ withCredentials: true } { withCredentials: true }
); );
@ -89,16 +96,16 @@ export class HistoryService {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('limit', String(limit)); params.set('limit', String(limit));
if (before) params.set('before', before); if (before) params.set('before', before);
return this.http.get<WatchHistoryItem[]>(`/proxy/api/user/history/watch?${params.toString()}`, { withCredentials: true }); return this.http.get<WatchHistoryItem[]>(`${this.apiBase()}/user/history/watch?${params.toString()}`, { withCredentials: true });
} }
// --- Delete single items --- // --- Delete single items ---
deleteSearchItem(id: string): Observable<void> { deleteSearchItem(id: string): Observable<void> {
return this.http.delete<void>(`/proxy/api/user/history/search/${encodeURIComponent(id)}`, { withCredentials: true }); return this.http.delete<void>(`${this.apiBase()}/user/history/search/${encodeURIComponent(id)}`, { withCredentials: true });
} }
deleteWatchItem(id: string): Observable<void> { deleteWatchItem(id: string): Observable<void> {
return this.http.delete<void>(`/proxy/api/user/history/watch/${encodeURIComponent(id)}`, { withCredentials: true }); return this.http.delete<void>(`${this.apiBase()}/user/history/watch/${encodeURIComponent(id)}`, { withCredentials: true });
} }
// Recherche dans l'historique des recherches // Recherche dans l'historique des recherches
@ -107,7 +114,7 @@ export class HistoryService {
params.set('q', query); params.set('q', query);
params.set('limit', String(limit)); params.set('limit', String(limit));
return this.http.get<SearchHistoryItem[]>( return this.http.get<SearchHistoryItem[]>(
`/proxy/api/user/history/search?${params.toString()}`, `${this.apiBase()}/user/history/search?${params.toString()}`,
{ withCredentials: true } { withCredentials: true }
); );
} }
@ -118,7 +125,7 @@ export class HistoryService {
params.set('q', query); params.set('q', query);
params.set('limit', String(limit)); params.set('limit', String(limit));
return this.http.get<WatchHistoryItem[]>( return this.http.get<WatchHistoryItem[]>(
`/proxy/api/user/history/watch?${params.toString()}`, `${this.apiBase()}/user/history/watch?${params.toString()}`,
{ withCredentials: true } { withCredentials: true }
); );
} }