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,
registerLocaleData,
setRootDomAdapter
} from "./chunk-H4LQPAO2.js";
} from "./chunk-QTXEQHHS.js";
import {
XhrFactory,
parseCookieValue
} from "./chunk-OUSM42MY.js";
import {
DOCUMENT,
IMAGE_CONFIG
} from "./chunk-FVA7C6JK.js";
} from "./chunk-XYAQCRC2.js";
import {
DOCUMENT
} from "./chunk-GFLMLXUS.js";
import "./chunk-HWYXSU2G.js";
import "./chunk-JRFR6BLO.js";
import "./chunk-MARUHEWW.js";

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@ -1,79 +1,88 @@
{
"hash": "f4c7eaa2",
"configHash": "d859ec53",
"hash": "26d31ed9",
"configHash": "69ef457b",
"lockfileHash": "9b1c4210",
"browserHash": "6942cbde",
"browserHash": "2055d91d",
"optimized": {
"@angular/common": {
"src": "../../../../../../node_modules/@angular/common/fesm2022/common.mjs",
"file": "@angular_common.js",
"fileHash": "92f641aa",
"fileHash": "58788c60",
"needsInterop": false
},
"@angular/common/http": {
"src": "../../../../../../node_modules/@angular/common/fesm2022/http.mjs",
"file": "@angular_common_http.js",
"fileHash": "3a8b8614",
"fileHash": "28e8e915",
"needsInterop": false
},
"@angular/core": {
"src": "../../../../../../node_modules/@angular/core/fesm2022/core.mjs",
"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
},
"@angular/forms": {
"src": "../../../../../../node_modules/@angular/forms/fesm2022/forms.mjs",
"file": "@angular_forms.js",
"fileHash": "5842af9d",
"fileHash": "76b8143b",
"needsInterop": false
},
"@angular/platform-browser": {
"src": "../../../../../../node_modules/@angular/platform-browser/fesm2022/platform-browser.mjs",
"file": "@angular_platform-browser.js",
"fileHash": "f65b040d",
"fileHash": "0219e3bd",
"needsInterop": false
},
"@angular/router": {
"src": "../../../../../../node_modules/@angular/router/fesm2022/router.mjs",
"file": "@angular_router.js",
"fileHash": "f605abad",
"fileHash": "e1237275",
"needsInterop": false
},
"@google/genai": {
"src": "../../../../../../node_modules/@google/genai/dist/web/index.mjs",
"file": "@google_genai.js",
"fileHash": "b3b8b992",
"fileHash": "89bd7952",
"needsInterop": false
},
"rxjs": {
"src": "../../../../../../node_modules/rxjs/dist/esm5/index.js",
"file": "rxjs.js",
"fileHash": "0cb397ac",
"fileHash": "460c479b",
"needsInterop": false
},
"rxjs/operators": {
"src": "../../../../../../node_modules/rxjs/dist/esm5/operators/index.js",
"file": "rxjs_operators.js",
"fileHash": "e706ebfa",
"fileHash": "0c6ce2bb",
"needsInterop": false
}
},
"chunks": {
"chunk-DYWB3JMR": {
"file": "chunk-DYWB3JMR.js"
"chunk-OUP2T4DW": {
"file": "chunk-OUP2T4DW.js"
},
"chunk-5DRVFSXL": {
"file": "chunk-5DRVFSXL.js"
"chunk-GND3ZHQO": {
"file": "chunk-GND3ZHQO.js"
},
"chunk-H4LQPAO2": {
"file": "chunk-H4LQPAO2.js"
"chunk-QTXEQHHS": {
"file": "chunk-QTXEQHHS.js"
},
"chunk-OUSM42MY": {
"file": "chunk-OUSM42MY.js"
},
"chunk-FVA7C6JK": {
"file": "chunk-FVA7C6JK.js"
"chunk-XYAQCRC2": {
"file": "chunk-XYAQCRC2.js"
},
"chunk-GFLMLXUS": {
"file": "chunk-GFLMLXUS.js"
},
"chunk-HWYXSU2G": {
"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 {
APP_BOOTSTRAP_LISTENER,
ApplicationRef,
Inject,
Injectable,
NgModule,
NgZone,
TransferState,
makeStateKey,
performanceMarkFeature,
setClassMetadata,
ɵɵdefineNgModule
} from "./chunk-XYAQCRC2.js";
import {
DOCUMENT,
DestroyRef,
EnvironmentInjector,
Inject,
Injectable,
InjectionToken,
Injector,
NgModule,
NgZone,
PendingTasks,
ResourceImpl,
RuntimeError,
TransferState,
assertInInjectionContext,
computed,
encapsulateResourceError,
@ -25,17 +31,13 @@ import {
inject,
linkedSignal,
makeEnvironmentProviders,
makeStateKey,
performanceMarkFeature,
runInInjectionContext,
setClassMetadata,
signal,
truncateMiddle,
ɵɵdefineInjectable,
ɵɵdefineInjector,
ɵɵdefineNgModule,
ɵɵinject
} from "./chunk-FVA7C6JK.js";
} from "./chunk-GFLMLXUS.js";
import {
Observable,
concatMap,
@ -2776,4 +2778,4 @@ export {
* License: MIT
*)
*/
//# sourceMappingURL=chunk-5DRVFSXL.js.map
//# sourceMappingURL=chunk-GND3ZHQO.js.map

View File

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

View File

@ -3,18 +3,13 @@ import {
Attribute,
ChangeDetectorRef,
DEFAULT_CURRENCY_CODE,
DOCUMENT,
DestroyRef,
Directive,
ElementRef,
Host,
IMAGE_CONFIG,
IMAGE_CONFIG_DEFAULTS,
INTERNAL_APPLICATION_ERROR_HANDLER,
Inject,
Injectable,
InjectionToken,
Injector,
Input,
IterableDiffers,
KeyValueDiffers,
@ -27,37 +22,44 @@ import {
Pipe,
Renderer2,
RendererStyleFlags2,
RuntimeError,
TemplateRef,
Version,
ViewContainerRef,
booleanAttribute,
createNgModule,
findLocaleData,
formatRuntimeError,
getLocaleCurrencyCode,
getLocalePluralCase,
inject,
isPromise,
isSubscribable,
numberAttribute,
performanceMarkFeature,
registerLocaleData,
setClassMetadata,
stringify,
untracked,
unwrapSafeValue,
ɵɵNgOnChangesFeature,
ɵɵdefineDirective,
ɵɵdefineInjectable,
ɵɵdefineInjector,
ɵɵdefineNgModule,
ɵɵdefinePipe,
ɵɵdirectiveInject,
ɵɵinject,
ɵɵinjectAttribute,
ɵɵ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 {
Subject
} from "./chunk-MARUHEWW.js";
@ -5188,4 +5190,4 @@ export {
* 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 cors from 'cors';
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 bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
@ -117,11 +135,16 @@ app.use(helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use(cors({
origin: true,
credentials: true,
}));
app.use(cors(corsOptions));
app.options('*', cors(corsOptions)); // Pré-vol CORS
// Logging des requêtes
app.use(requestLogger);
// Routes API
app.use('/api', r);
// Downloads directory
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) --------------------
app.get('/api/search', async (req, res) => {
try {
console.log('[SEARCH] Requête reçue - Query:', 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)
const requested = typeof providers === 'string' ? String(providers) : '';
const validProviders = validateProviders(requested);
@ -1403,8 +1436,8 @@ app.get('/api/search', async (req, res) => {
validProviders.map((providerId) => {
const mod = providerRegistry[/** @type {any} */(providerId)];
if (!mod || typeof mod.search !== 'function') return Promise.resolve([]);
// Basic options: limit per provider; could be extended with page, etc.
return Promise.resolve().then(() => mod.search(q, { limit: 10 }));
// Basic options include pagination and sort hints
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) {
return res.status(500).json({ error: 'search_failed', details: String(e?.message || e) });
}

View File

@ -10,18 +10,22 @@
* @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 = {
id: 'yt',
label: 'YouTube',
async search(q, opts) {
const { limit = 10 } = opts;
const { limit = 10, sort = 'relevance' } = opts || {};
try {
const API_KEY = process.env.YOUTUBE_API_KEY;
if (!API_KEY) {
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(
`https://www.googleapis.com/youtube/v3/search?` +
new URLSearchParams({
@ -30,7 +34,7 @@ const handler = {
type: 'video',
maxResults: Math.min(limit, 50).toString(),
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 { 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 { 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 {
title: string;
id: string;
duration?: number;
isShort?: boolean;
thumbnail?: string;
uploaderName?: string;
url?: string;
type?: string; // 'video' | 'channel' | ...
}
export type SuggestionItem = SuggestionItemV1;
export type SearchResponse = SearchResponseV1;
export interface SearchResponse {
q: string;
providers: ProviderId[];
groups: Record<ProviderId, SuggestionItem[]>;
}
type CacheKey = string;
interface CacheEntry { t: number; data: SearchResult; }
@Injectable({ providedIn: 'root' })
export class SearchService {
private http = inject(HttpClient);
// Subjects for params from UI/URL
readonly q$ = new BehaviorSubject<string>('');
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),
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(
switchMap(([q, prov]) => {
const providersParam = (prov === 'all')
? 'yt,dm,tw,pt,od,ru'
: (Array.isArray(prov) && prov.length > 0 ? prov.join(',') : '');
return this.http.get<SearchResponse>('/api/search', {
params: { q, providers: providersParam }
});
})
filter(([q]) => typeof q === 'string' && q.trim().length >= 2),
switchMap(([q, prov, page, pageSize, sort]) => from(this.runAdapters({
q: String(q),
pageToken: String(page || 1),
sort: (sort as any) || 'relevance',
// 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
setQuery(q: string) { this.q$.next(q || ''); }
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">
<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">
<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">
<div class="flex items-start gap-2">
<span class="text-slate-400 mt-0.5">
@ -80,15 +80,7 @@
</div>
<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"
[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)
}">
[ngStyle]="getProviderColors(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">
<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>
@ -122,7 +114,7 @@
<ul *ngIf="searchHistory().length > 0" class="space-y-3">
<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">
<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">
<div class="flex items-start gap-2">
<span class="text-slate-400 mt-0.5">
@ -136,15 +128,7 @@
</div>
<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"
[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)
}">
[ngStyle]="getProviderColors(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">
<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>

View File

@ -1,5 +1,4 @@
import { ChangeDetectionStrategy, Component, inject, signal, computed, effect } from '@angular/core';
import { NgClass } from '@angular/common';
import { CommonModule, DatePipe } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
@ -12,7 +11,7 @@ import { HistoryService, SearchHistoryItem, WatchHistoryItem } from '../../../se
standalone: true,
templateUrl: './history.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterModule, FormsModule, NgClass]
imports: [CommonModule, RouterModule, FormsModule]
})
export class HistoryComponent {
private history = inject(HistoryService);
@ -32,6 +31,20 @@ export class HistoryComponent {
// Sujet pour la recherche avec debounce
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() {
// Chargement initial des données
this.reload();
@ -151,13 +164,25 @@ export class HistoryComponent {
});
}
// Get provider from search filters or query params
getSearchProvider(item: SearchHistoryItem): { name: string; id: string } {
// Try to get provider from filters first
getSearchProvider(item: SearchHistoryItem): { name: string; id: string; providers?: string[] } {
// Try to get providers from filters first
if (item.filters_json) {
try {
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);
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
}
// 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
return { name: 'Tous', id: 'all' };
}
@ -207,6 +226,10 @@ export class HistoryComponent {
}
}
getProvidersWithInfo(item: SearchHistoryItem) {
return [];
}
// Format provider ID to a display name
private formatProviderName(providerId: string): string {
const providerMap: { [key: string]: string } = {
@ -225,27 +248,66 @@ export class HistoryComponent {
return this.formatProviderName(providerId);
}
// Brand colors for provider badges (inline to avoid Tailwind purge issues)
getProviderColors(providerId: string): { [key: string]: string } {
const id = (providerId || '').toLowerCase();
// Obtenir les styles pour un provider
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) {
case 'youtube':
return { backgroundColor: 'rgba(220, 38, 38, 0.15)', color: 'rgb(248, 113, 113)', borderColor: 'rgba(239, 68, 68, 0.3)' };
case 'vimeo':
return { backgroundColor: 'rgba(2, 132, 199, 0.15)', color: 'rgb(125, 211, 252)', borderColor: 'rgba(14, 165, 233, 0.3)' };
case 'yt':
bgColor = 'rgb(220 38 38 / 0.15)';
textColor = 'rgb(248 113 113)';
borderColor = 'rgb(239 68 68 / 0.3)';
break;
case 'dailymotion':
return { backgroundColor: 'rgba(37, 99, 235, 0.15)', color: 'rgb(147, 197, 253)', borderColor: 'rgba(59, 130, 246, 0.3)' };
case 'peertube':
return { backgroundColor: 'rgba(245, 158, 11, 0.15)', color: 'rgb(252, 211, 77)', borderColor: 'rgba(245, 158, 11, 0.3)' };
case 'rumble':
return { backgroundColor: 'rgba(22, 163, 74, 0.15)', color: 'rgb(134, 239, 172)', borderColor: 'rgba(34, 197, 94, 0.3)' };
case 'dm':
bgColor = 'rgb(37 99 235 / 0.15)';
textColor = 'rgb(96 165 250)';
borderColor = 'rgb(59 130 246 / 0.3)';
break;
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':
return { backgroundColor: 'rgba(236, 72, 153, 0.15)', color: 'rgb(251, 207, 232)', borderColor: 'rgba(236, 72, 153, 0.3)' };
default:
return { backgroundColor: 'rgba(30, 41, 59, 0.8)', color: 'rgb(203, 213, 225)', borderColor: 'rgb(51, 65, 85)' };
case 'od':
bgColor = 'rgb(22 163 74 / 0.15)';
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
@ -255,7 +317,6 @@ export class HistoryComponent {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
} else {

View File

@ -102,6 +102,8 @@ export class HeaderComponent {
qp.providers = 'yt,dm,tw,pt,od,ru';
}
if (theme) qp.theme = theme;
try { console.debug('[Header] onSearchBoxSubmit -> navigate', qp); } catch {}
this.suggestionsOpen.set(false);
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 -->
<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 -->
@ -25,14 +25,20 @@
</ng-container>
<!-- Text input -->
<input type="search"
<input #qInput type="search"
class="flex-1 bg-transparent outline-none text-slate-100 placeholder-slate-400 px-2"
[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 -->
<button type="button" (click)="openPicker()" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600">@</button>
<button type="submit" class="ml-1 px-3 py-1 rounded bg-red-600 hover:bg-red-500 text-white">Search</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 *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>
<!-- 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 { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { ProviderId, PROVIDERS } from '../../app/core/providers/provider-registry';
import { ProviderPickerComponent } from './provider-picker.component';
import { SearchService } from '../../app/search/search.service';
import { UserService, type UserPreferences } from '../../services/user.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
@Component({
selector: 'app-search-box',
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'
})
export class SearchBoxComponent {
private search = inject(SearchService);
private users = inject(UserService);
private history = inject(HistoryService);
@ViewChild('qInput', { static: false }) qInputRef?: ElementRef<HTMLInputElement>;
// Input/Output bindings
@Input() placeholder: string = 'Search videos…';
@Output() submitted = new EventEmitter<{ q: string; providers: ProviderId[] | 'all' }>();
// Emits debounced query changes for autosuggest
@Output() searchChange = new EventEmitter<string>();
// Local state signals
query = signal('');
@ -56,11 +73,23 @@ export class SearchBoxComponent {
});
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
effect(() => {
if (hydratedFromService) return; // only hydrate once
const q = this.search.q$.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') {
this.providerMode.set('all');
this.selected.set([]);
@ -68,7 +97,7 @@ export class SearchBoxComponent {
this.providerMode.set('custom');
this.selected.set(prov);
}
hydratedFromService = true;
});
// 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
queryUpdate(value: string) {
try { console.log('[SearchBox] queryUpdate', { value }); } catch {}
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);
if (m) {
const term = m[2] || '';
@ -153,8 +186,14 @@ export class SearchBoxComponent {
if (pid) { ev.preventDefault(); this.toggleChip(pid); }
}
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.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) {
ev.preventDefault();
const q = (this.query() || '').trim();
if (!q) return;
try { ev.preventDefault(); } catch {}
let q = (this.query() || '').trim();
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();
// Update service for global listeners
this.search.setQuery(q);
this.search.setProviders(prov);
console.log('[SearchBox] Emitting submit event', { q, providers: prov });
// Emit payload to parent (Header/Search page)
this.submitted.emit({ q, providers: prov });
console.log('[SearchBox] Submit event emitted');
}
openPicker() { this.pickerOpen.set(true); }
@ -244,4 +306,7 @@ export class SearchBoxComponent {
document.body.style.overflow = open ? 'hidden' : '';
} 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">
<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()) {
<div class="mb-4 bg-amber-900/40 border border-amber-600 text-amber-200 p-3 rounded">
@ -27,21 +30,68 @@
</div>
}
<!-- Unified multi-provider grouped suggestions -->
<!-- Unified per-provider sections -->
@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
#searchInput
type="text"
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"
placeholder="Rechercher..."
[value]="q()"
(input)="onSearchInput($event)"
(keydown.enter)="onSearchEnter($event)"
(keydown.escape)="closeSearchResults()"
/>
@if (hasQuery()) {
<div class="my-4 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div class="flex items-center gap-2">
<label class="text-sm text-slate-300" for="sortSelect">Trier par</label>
<select id="sortSelect" class="bg-slate-900 text-slate-100 border border-slate-700 rounded px-2 py-1 text-sm"
[value]="sortParam()" (change)="onSortChange($event)">
<option value="relevance">Pertinence</option>
<option value="date">Date</option>
<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()) {
<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 { 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 { Video } from '../../models/video.model';
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 { LikeButtonComponent } from '../shared/components/like-button/like-button.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 type { ProviderId } from '../../app/core/providers/provider-registry';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-search',
standalone: true,
templateUrl: './search.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent, AddToPlaylistComponent, SearchSuggestionsComponent]
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent, AddToPlaylistComponent]
})
export class SearchComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private api = inject(YoutubeApiService);
private instances = inject(InstanceService);
private history = inject(HistoryService);
@ -46,10 +47,16 @@ export class SearchComponent {
providerParam = signal<Provider | null>(null);
themeParam = signal<string | null>(null);
providersListParam = signal<ProviderId[] | 'all' | null>(null);
pageParam = signal<number>(1);
sortParam = signal<'relevance' | 'date' | 'views'>('relevance');
// Unified multi-provider response (grouped suggestions)
groups = signal<Record<ProviderId, any[]>>({} as any);
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);
providerLabel = computed(() => {
@ -60,7 +67,33 @@ export class SearchComponent {
}
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`
selectedProviderForView = computed(() => this.providerParam() || this.instances.selectedProvider());
@ -68,6 +101,16 @@ export class SearchComponent {
// For Twitch provider: 'twitch_all' | 'twitch_live' | 'twitch_vod'.
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)
filteredResults = computed(() => {
const tag = this.filterTag();
@ -247,24 +290,29 @@ export class SearchComponent {
});
// Subscribe once to unified multi-provider results and reflect to UI
this.unified.request$.subscribe({
this.unified.request$.pipe(takeUntilDestroyed()).subscribe({
next: (resp: SearchResponse) => {
this.groups.set(resp.groups as any);
this.loading.set(false);
this.error.set(null);
},
error: () => {
this.groups.set({} as any);
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)
this.route.queryParamMap.subscribe((pm) => {
this.route.queryParamMap.pipe(takeUntilDestroyed()).subscribe((pm) => {
const q = (pm.get('q') || '').trim();
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 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
let providersList: ProviderId[] | 'all' | null = null;
if (providersParam) {
@ -274,6 +322,13 @@ export class SearchComponent {
this.providerParam.set(prov);
this.themeParam.set(theme);
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) {
this.notice.set(null);
@ -285,15 +340,33 @@ export class SearchComponent {
const providersToUse = providersList || [provider as ProviderId];
this.unified.setQuery(q);
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.loading.set(true);
this.groups.set({} as any); // Clear previous results
this.error.set(null);
} else {
this.results.set([]);
this.nextCursor.set(null);
this.loading.set(false);
this.groups.set({} as any);
this.showUnified.set(false);
this.error.set(null);
}
});
@ -464,30 +537,21 @@ export class SearchComponent {
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>;
onSearchEnter(event: Event) {
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();
}
}
}
// Legacy input handlers removed; SearchBox handles keyboard and submit
closeSearchResults() {
// Cette méthode sera appelée pour fermer les résultats de recherche
@ -506,4 +570,29 @@ export class SearchComponent {
setFilterTag(key: string) {
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;
query: string;
filters_json?: string | null;
providers?: string[]; // Liste des providers utilisés (ex: ['yt', 'dm', 'tw'])
created_at: string;
}
@ -39,10 +40,16 @@ export class HistoryService {
// --- Search ---
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 }>(
'/proxy/api/user/history/search',
url,
{ query, filters },
{ 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();
params.set('limit', String(limit));
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 ---
recordWatchStart(provider: string, videoId: string, title?: string | null, watchedAt?: string, thumbnail?: string | null, durationSeconds?: number): Observable<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 },
{ withCredentials: true }
);
@ -89,16 +96,16 @@ export class HistoryService {
const params = new URLSearchParams();
params.set('limit', String(limit));
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 ---
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> {
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
@ -107,7 +114,7 @@ export class HistoryService {
params.set('q', query);
params.set('limit', String(limit));
return this.http.get<SearchHistoryItem[]>(
`/proxy/api/user/history/search?${params.toString()}`,
`${this.apiBase()}/user/history/search?${params.toString()}`,
{ withCredentials: true }
);
}
@ -118,7 +125,7 @@ export class HistoryService {
params.set('q', query);
params.set('limit', String(limit));
return this.http.get<WatchHistoryItem[]>(
`/proxy/api/user/history/watch?${params.toString()}`,
`${this.apiBase()}/user/history/watch?${params.toString()}`,
{ withCredentials: true }
);
}