first commit

This commit is contained in:
Bruno Charest 2025-09-14 23:05:30 -04:00
commit 2866d74b32
122 changed files with 95474 additions and 0 deletions

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,6 @@
{
"workspace": {
"root": "C:\\Users\\bruno\\Downloads\\newtube",
"uuid": "969ba79d-ef50-49c8-8372-7418cabbcb6f"
}
}

View File

@ -0,0 +1,205 @@
import {
APP_BASE_HREF,
AsyncPipe,
BrowserPlatformLocation,
CommonModule,
CurrencyPipe,
DATE_PIPE_DEFAULT_OPTIONS,
DATE_PIPE_DEFAULT_TIMEZONE,
DatePipe,
DecimalPipe,
DomAdapter,
FormStyle,
FormatWidth,
HashLocationStrategy,
I18nPluralPipe,
I18nSelectPipe,
IMAGE_LOADER,
JsonPipe,
KeyValuePipe,
LOCATION_INITIALIZED,
Location,
LocationStrategy,
LowerCasePipe,
NgClass,
NgComponentOutlet,
NgForOf,
NgForOfContext,
NgIf,
NgIfContext,
NgLocaleLocalization,
NgLocalization,
NgOptimizedImage,
NgPlural,
NgPluralCase,
NgStyle,
NgSwitch,
NgSwitchCase,
NgSwitchDefault,
NgTemplateOutlet,
NullViewportScroller,
NumberFormatStyle,
NumberSymbol,
PLATFORM_BROWSER_ID,
PLATFORM_SERVER_ID,
PRECONNECT_CHECK_BLOCKLIST,
PathLocationStrategy,
PercentPipe,
PlatformLocation,
PlatformNavigation,
Plural,
SlicePipe,
TitleCasePipe,
TranslationWidth,
UpperCasePipe,
VERSION,
ViewportScroller,
WeekDay,
formatCurrency,
formatDate,
formatNumber,
formatPercent,
getCurrencySymbol,
getDOM,
getLocaleCurrencyCode,
getLocaleCurrencyName,
getLocaleCurrencySymbol,
getLocaleDateFormat,
getLocaleDateTimeFormat,
getLocaleDayNames,
getLocaleDayPeriods,
getLocaleDirection,
getLocaleEraNames,
getLocaleExtraDayPeriodRules,
getLocaleExtraDayPeriods,
getLocaleFirstDayOfWeek,
getLocaleId,
getLocaleMonthNames,
getLocaleNumberFormat,
getLocaleNumberSymbol,
getLocalePluralCase,
getLocaleTimeFormat,
getLocaleWeekEndRange,
getNumberOfCurrencyDigits,
isPlatformBrowser,
isPlatformServer,
normalizeQueryParams,
provideCloudflareLoader,
provideCloudinaryLoader,
provideImageKitLoader,
provideImgixLoader,
provideNetlifyLoader,
registerLocaleData,
setRootDomAdapter
} from "./chunk-H4LQPAO2.js";
import {
XhrFactory,
parseCookieValue
} from "./chunk-OUSM42MY.js";
import {
DOCUMENT,
IMAGE_CONFIG
} from "./chunk-FVA7C6JK.js";
import "./chunk-HWYXSU2G.js";
import "./chunk-JRFR6BLO.js";
import "./chunk-MARUHEWW.js";
import "./chunk-GOMI4DH3.js";
export {
APP_BASE_HREF,
AsyncPipe,
BrowserPlatformLocation,
CommonModule,
CurrencyPipe,
DATE_PIPE_DEFAULT_OPTIONS,
DATE_PIPE_DEFAULT_TIMEZONE,
DOCUMENT,
DatePipe,
DecimalPipe,
FormStyle,
FormatWidth,
HashLocationStrategy,
I18nPluralPipe,
I18nSelectPipe,
IMAGE_CONFIG,
IMAGE_LOADER,
JsonPipe,
KeyValuePipe,
LOCATION_INITIALIZED,
Location,
LocationStrategy,
LowerCasePipe,
NgClass,
NgComponentOutlet,
NgForOf as NgFor,
NgForOf,
NgForOfContext,
NgIf,
NgIfContext,
NgLocaleLocalization,
NgLocalization,
NgOptimizedImage,
NgPlural,
NgPluralCase,
NgStyle,
NgSwitch,
NgSwitchCase,
NgSwitchDefault,
NgTemplateOutlet,
NumberFormatStyle,
NumberSymbol,
PRECONNECT_CHECK_BLOCKLIST,
PathLocationStrategy,
PercentPipe,
PlatformLocation,
Plural,
SlicePipe,
TitleCasePipe,
TranslationWidth,
UpperCasePipe,
VERSION,
ViewportScroller,
WeekDay,
XhrFactory,
formatCurrency,
formatDate,
formatNumber,
formatPercent,
getCurrencySymbol,
getLocaleCurrencyCode,
getLocaleCurrencyName,
getLocaleCurrencySymbol,
getLocaleDateFormat,
getLocaleDateTimeFormat,
getLocaleDayNames,
getLocaleDayPeriods,
getLocaleDirection,
getLocaleEraNames,
getLocaleExtraDayPeriodRules,
getLocaleExtraDayPeriods,
getLocaleFirstDayOfWeek,
getLocaleId,
getLocaleMonthNames,
getLocaleNumberFormat,
getLocaleNumberSymbol,
getLocalePluralCase,
getLocaleTimeFormat,
getLocaleWeekEndRange,
getNumberOfCurrencyDigits,
isPlatformBrowser,
isPlatformServer,
provideCloudflareLoader,
provideCloudinaryLoader,
provideImageKitLoader,
provideImgixLoader,
provideNetlifyLoader,
registerLocaleData,
DomAdapter as ɵDomAdapter,
NullViewportScroller as ɵNullViewportScroller,
PLATFORM_BROWSER_ID as ɵPLATFORM_BROWSER_ID,
PLATFORM_SERVER_ID as ɵPLATFORM_SERVER_ID,
PlatformNavigation as ɵPlatformNavigation,
getDOM as ɵgetDOM,
normalizeQueryParams as ɵnormalizeQueryParams,
parseCookieValue as ɵparseCookieValue,
setRootDomAdapter as ɵsetRootDomAdapter
};

View File

@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

View File

@ -0,0 +1,89 @@
import {
FetchBackend,
HTTP_INTERCEPTORS,
HTTP_ROOT_INTERCEPTOR_FNS,
HTTP_TRANSFER_CACHE_ORIGIN_MAP,
HttpBackend,
HttpClient,
HttpClientJsonpModule,
HttpClientModule,
HttpClientXsrfModule,
HttpContext,
HttpContextToken,
HttpErrorResponse,
HttpEventType,
HttpFeatureKind,
HttpHandler,
HttpHeaderResponse,
HttpHeaders,
HttpInterceptorHandler,
HttpParams,
HttpRequest,
HttpResponse,
HttpResponseBase,
HttpStatusCode,
HttpUrlEncodingCodec,
HttpXhrBackend,
HttpXsrfTokenExtractor,
JsonpClientBackend,
JsonpInterceptor,
REQUESTS_CONTRIBUTE_TO_STABILITY,
httpResource,
provideHttpClient,
withFetch,
withHttpTransferCache,
withInterceptors,
withInterceptorsFromDi,
withJsonpSupport,
withNoXsrfProtection,
withRequestsMadeViaParent,
withXsrfConfiguration
} from "./chunk-5DRVFSXL.js";
import "./chunk-OUSM42MY.js";
import "./chunk-FVA7C6JK.js";
import "./chunk-HWYXSU2G.js";
import "./chunk-JRFR6BLO.js";
import "./chunk-MARUHEWW.js";
import "./chunk-GOMI4DH3.js";
export {
FetchBackend,
HTTP_INTERCEPTORS,
HTTP_TRANSFER_CACHE_ORIGIN_MAP,
HttpBackend,
HttpClient,
HttpClientJsonpModule,
HttpClientModule,
HttpClientXsrfModule,
HttpContext,
HttpContextToken,
HttpErrorResponse,
HttpEventType,
HttpFeatureKind,
HttpHandler,
HttpHeaderResponse,
HttpHeaders,
HttpParams,
HttpRequest,
HttpResponse,
HttpResponseBase,
HttpStatusCode,
HttpUrlEncodingCodec,
HttpXhrBackend,
HttpXsrfTokenExtractor,
JsonpClientBackend,
JsonpInterceptor,
httpResource,
provideHttpClient,
withFetch,
withInterceptors,
withInterceptorsFromDi,
withJsonpSupport,
withNoXsrfProtection,
withRequestsMadeViaParent,
withXsrfConfiguration,
HTTP_ROOT_INTERCEPTOR_FNS as ɵHTTP_ROOT_INTERCEPTOR_FNS,
HttpInterceptorHandler as ɵHttpInterceptingHandler,
HttpInterceptorHandler as ɵHttpInterceptorHandler,
REQUESTS_CONTRIBUTE_TO_STABILITY as ɵREQUESTS_CONTRIBUTE_TO_STABILITY,
withHttpTransferCache as ɵwithHttpTransferCache
};

View File

@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,85 @@
import {
BrowserDomAdapter,
BrowserGetTestability,
BrowserModule,
By,
DomEventsPlugin,
DomRendererFactory2,
DomSanitizer,
DomSanitizerImpl,
EVENT_MANAGER_PLUGINS,
EventManager,
EventManagerPlugin,
HAMMER_GESTURE_CONFIG,
HAMMER_LOADER,
HammerGestureConfig,
HammerGesturesPlugin,
HammerModule,
HydrationFeatureKind,
KeyEventsPlugin,
Meta,
REMOVE_STYLES_ON_COMPONENT_DESTROY,
SharedStylesHost,
Title,
VERSION,
bootstrapApplication,
createApplication,
disableDebugTools,
enableDebugTools,
platformBrowser,
provideClientHydration,
provideProtractorTestingSupport,
withEventReplay,
withHttpTransferCacheOptions,
withI18nSupport,
withIncrementalHydration,
withNoHttpTransferCache
} from "./chunk-DYWB3JMR.js";
import "./chunk-5DRVFSXL.js";
import {
getDOM
} from "./chunk-H4LQPAO2.js";
import "./chunk-OUSM42MY.js";
import "./chunk-FVA7C6JK.js";
import "./chunk-HWYXSU2G.js";
import "./chunk-JRFR6BLO.js";
import "./chunk-MARUHEWW.js";
import "./chunk-GOMI4DH3.js";
export {
BrowserModule,
By,
DomSanitizer,
EVENT_MANAGER_PLUGINS,
EventManager,
EventManagerPlugin,
HAMMER_GESTURE_CONFIG,
HAMMER_LOADER,
HammerGestureConfig,
HammerModule,
HydrationFeatureKind,
Meta,
REMOVE_STYLES_ON_COMPONENT_DESTROY,
Title,
VERSION,
bootstrapApplication,
createApplication,
disableDebugTools,
enableDebugTools,
platformBrowser,
provideClientHydration,
provideProtractorTestingSupport,
withEventReplay,
withHttpTransferCacheOptions,
withI18nSupport,
withIncrementalHydration,
withNoHttpTransferCache,
BrowserDomAdapter as ɵBrowserDomAdapter,
BrowserGetTestability as ɵBrowserGetTestability,
DomEventsPlugin as ɵDomEventsPlugin,
DomRendererFactory2 as ɵDomRendererFactory2,
DomSanitizerImpl as ɵDomSanitizerImpl,
HammerGesturesPlugin as ɵHammerGesturesPlugin,
KeyEventsPlugin as ɵKeyEventsPlugin,
SharedStylesHost as ɵSharedStylesHost,
getDOM as ɵgetDOM
};

View File

@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

File diff suppressed because it is too large Load Diff

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

@ -0,0 +1,91 @@
{
"hash": "23edc511",
"configHash": "2277c002",
"lockfileHash": "c86d7ad1",
"browserHash": "0d41a6ee",
"optimized": {
"@angular/common": {
"src": "../../../../../../node_modules/@angular/common/fesm2022/common.mjs",
"file": "@angular_common.js",
"fileHash": "ed7bea66",
"needsInterop": false
},
"@angular/common/http": {
"src": "../../../../../../node_modules/@angular/common/fesm2022/http.mjs",
"file": "@angular_common_http.js",
"fileHash": "d8740647",
"needsInterop": false
},
"@angular/core": {
"src": "../../../../../../node_modules/@angular/core/fesm2022/core.mjs",
"file": "@angular_core.js",
"fileHash": "fc2503ca",
"needsInterop": false
},
"@angular/forms": {
"src": "../../../../../../node_modules/@angular/forms/fesm2022/forms.mjs",
"file": "@angular_forms.js",
"fileHash": "36d0134e",
"needsInterop": false
},
"@angular/platform-browser": {
"src": "../../../../../../node_modules/@angular/platform-browser/fesm2022/platform-browser.mjs",
"file": "@angular_platform-browser.js",
"fileHash": "c0c2a1fa",
"needsInterop": false
},
"@angular/router": {
"src": "../../../../../../node_modules/@angular/router/fesm2022/router.mjs",
"file": "@angular_router.js",
"fileHash": "381708ba",
"needsInterop": false
},
"@google/genai": {
"src": "../../../../../../node_modules/@google/genai/dist/web/index.mjs",
"file": "@google_genai.js",
"fileHash": "a56c743a",
"needsInterop": false
},
"rxjs": {
"src": "../../../../../../node_modules/rxjs/dist/esm5/index.js",
"file": "rxjs.js",
"fileHash": "c69b23b3",
"needsInterop": false
},
"rxjs/operators": {
"src": "../../../../../../node_modules/rxjs/dist/esm5/operators/index.js",
"file": "rxjs_operators.js",
"fileHash": "56086c3c",
"needsInterop": false
}
},
"chunks": {
"chunk-DYWB3JMR": {
"file": "chunk-DYWB3JMR.js"
},
"chunk-5DRVFSXL": {
"file": "chunk-5DRVFSXL.js"
},
"chunk-H4LQPAO2": {
"file": "chunk-H4LQPAO2.js"
},
"chunk-OUSM42MY": {
"file": "chunk-OUSM42MY.js"
},
"chunk-FVA7C6JK": {
"file": "chunk-FVA7C6JK.js"
},
"chunk-HWYXSU2G": {
"file": "chunk-HWYXSU2G.js"
},
"chunk-JRFR6BLO": {
"file": "chunk-JRFR6BLO.js"
},
"chunk-MARUHEWW": {
"file": "chunk-MARUHEWW.js"
},
"chunk-GOMI4DH3": {
"file": "chunk-GOMI4DH3.js"
}
}
}

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,37 @@
var __defProp = Object.defineProperty;
var __defProps = Object.defineProperties;
var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
var __getOwnPropSymbols = Object.getOwnPropertySymbols;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __propIsEnum = Object.prototype.propertyIsEnumerable;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __spreadValues = (a, b) => {
for (var prop in b ||= {})
if (__hasOwnProp.call(b, prop))
__defNormalProp(a, prop, b[prop]);
if (__getOwnPropSymbols)
for (var prop of __getOwnPropSymbols(b)) {
if (__propIsEnum.call(b, prop))
__defNormalProp(a, prop, b[prop]);
}
return a;
};
var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
var __objRest = (source, exclude) => {
var target = {};
for (var prop in source)
if (__hasOwnProp.call(source, prop) && exclude.indexOf(prop) < 0)
target[prop] = source[prop];
if (source != null && __getOwnPropSymbols)
for (var prop of __getOwnPropSymbols(source)) {
if (exclude.indexOf(prop) < 0 && __propIsEnum.call(source, prop))
target[prop] = source[prop];
}
return target;
};
export {
__spreadValues,
__spreadProps,
__objRest
};

View File

@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,911 @@
import {
AsyncAction,
AsyncScheduler,
AsyncSubject,
EMPTY,
EmptyError,
Observable,
SafeSubscriber,
Subject,
Subscription,
__extends,
__generator,
__read,
__spreadArray,
argsArgArrayOrObject,
createObject,
createOperatorSubscriber,
filter,
from,
identity,
innerFrom,
isArrayLike,
isFunction,
isScheduler,
mapOneOrManyArgs,
mergeAll,
mergeMap,
noop,
not,
observeOn,
popNumber,
popResultSelector,
popScheduler,
scheduleIterable,
subscribeOn
} from "./chunk-MARUHEWW.js";
// node_modules/rxjs/dist/esm5/internal/scheduler/performanceTimestampProvider.js
var performanceTimestampProvider = {
now: function() {
return (performanceTimestampProvider.delegate || performance).now();
},
delegate: void 0
};
// node_modules/rxjs/dist/esm5/internal/scheduler/animationFrameProvider.js
var animationFrameProvider = {
schedule: function(callback) {
var request = requestAnimationFrame;
var cancel = cancelAnimationFrame;
var delegate = animationFrameProvider.delegate;
if (delegate) {
request = delegate.requestAnimationFrame;
cancel = delegate.cancelAnimationFrame;
}
var handle = request(function(timestamp2) {
cancel = void 0;
callback(timestamp2);
});
return new Subscription(function() {
return cancel === null || cancel === void 0 ? void 0 : cancel(handle);
});
},
requestAnimationFrame: function() {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
var delegate = animationFrameProvider.delegate;
return ((delegate === null || delegate === void 0 ? void 0 : delegate.requestAnimationFrame) || requestAnimationFrame).apply(void 0, __spreadArray([], __read(args)));
},
cancelAnimationFrame: function() {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
var delegate = animationFrameProvider.delegate;
return ((delegate === null || delegate === void 0 ? void 0 : delegate.cancelAnimationFrame) || cancelAnimationFrame).apply(void 0, __spreadArray([], __read(args)));
},
delegate: void 0
};
// node_modules/rxjs/dist/esm5/internal/observable/dom/animationFrames.js
function animationFrames(timestampProvider) {
return timestampProvider ? animationFramesFactory(timestampProvider) : DEFAULT_ANIMATION_FRAMES;
}
function animationFramesFactory(timestampProvider) {
return new Observable(function(subscriber) {
var provider = timestampProvider || performanceTimestampProvider;
var start = provider.now();
var id = 0;
var run = function() {
if (!subscriber.closed) {
id = animationFrameProvider.requestAnimationFrame(function(timestamp2) {
id = 0;
var now = provider.now();
subscriber.next({
timestamp: timestampProvider ? now : timestamp2,
elapsed: now - start
});
run();
});
}
};
run();
return function() {
if (id) {
animationFrameProvider.cancelAnimationFrame(id);
}
};
});
}
var DEFAULT_ANIMATION_FRAMES = animationFramesFactory();
// node_modules/rxjs/dist/esm5/internal/util/Immediate.js
var nextHandle = 1;
var resolved;
var activeHandles = {};
function findAndClearHandle(handle) {
if (handle in activeHandles) {
delete activeHandles[handle];
return true;
}
return false;
}
var Immediate = {
setImmediate: function(cb) {
var handle = nextHandle++;
activeHandles[handle] = true;
if (!resolved) {
resolved = Promise.resolve();
}
resolved.then(function() {
return findAndClearHandle(handle) && cb();
});
return handle;
},
clearImmediate: function(handle) {
findAndClearHandle(handle);
}
};
// node_modules/rxjs/dist/esm5/internal/scheduler/immediateProvider.js
var setImmediate = Immediate.setImmediate;
var clearImmediate = Immediate.clearImmediate;
var immediateProvider = {
setImmediate: function() {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
var delegate = immediateProvider.delegate;
return ((delegate === null || delegate === void 0 ? void 0 : delegate.setImmediate) || setImmediate).apply(void 0, __spreadArray([], __read(args)));
},
clearImmediate: function(handle) {
var delegate = immediateProvider.delegate;
return ((delegate === null || delegate === void 0 ? void 0 : delegate.clearImmediate) || clearImmediate)(handle);
},
delegate: void 0
};
// node_modules/rxjs/dist/esm5/internal/scheduler/AsapAction.js
var AsapAction = (function(_super) {
__extends(AsapAction2, _super);
function AsapAction2(scheduler, work) {
var _this = _super.call(this, scheduler, work) || this;
_this.scheduler = scheduler;
_this.work = work;
return _this;
}
AsapAction2.prototype.requestAsyncId = function(scheduler, id, delay2) {
if (delay2 === void 0) {
delay2 = 0;
}
if (delay2 !== null && delay2 > 0) {
return _super.prototype.requestAsyncId.call(this, scheduler, id, delay2);
}
scheduler.actions.push(this);
return scheduler._scheduled || (scheduler._scheduled = immediateProvider.setImmediate(scheduler.flush.bind(scheduler, void 0)));
};
AsapAction2.prototype.recycleAsyncId = function(scheduler, id, delay2) {
var _a;
if (delay2 === void 0) {
delay2 = 0;
}
if (delay2 != null ? delay2 > 0 : this.delay > 0) {
return _super.prototype.recycleAsyncId.call(this, scheduler, id, delay2);
}
var actions = scheduler.actions;
if (id != null && ((_a = actions[actions.length - 1]) === null || _a === void 0 ? void 0 : _a.id) !== id) {
immediateProvider.clearImmediate(id);
if (scheduler._scheduled === id) {
scheduler._scheduled = void 0;
}
}
return void 0;
};
return AsapAction2;
})(AsyncAction);
// node_modules/rxjs/dist/esm5/internal/scheduler/AsapScheduler.js
var AsapScheduler = (function(_super) {
__extends(AsapScheduler2, _super);
function AsapScheduler2() {
return _super !== null && _super.apply(this, arguments) || this;
}
AsapScheduler2.prototype.flush = function(action) {
this._active = true;
var flushId = this._scheduled;
this._scheduled = void 0;
var actions = this.actions;
var error;
action = action || actions.shift();
do {
if (error = action.execute(action.state, action.delay)) {
break;
}
} while ((action = actions[0]) && action.id === flushId && actions.shift());
this._active = false;
if (error) {
while ((action = actions[0]) && action.id === flushId && actions.shift()) {
action.unsubscribe();
}
throw error;
}
};
return AsapScheduler2;
})(AsyncScheduler);
// node_modules/rxjs/dist/esm5/internal/scheduler/asap.js
var asapScheduler = new AsapScheduler(AsapAction);
var asap = asapScheduler;
// node_modules/rxjs/dist/esm5/internal/scheduler/QueueAction.js
var QueueAction = (function(_super) {
__extends(QueueAction2, _super);
function QueueAction2(scheduler, work) {
var _this = _super.call(this, scheduler, work) || this;
_this.scheduler = scheduler;
_this.work = work;
return _this;
}
QueueAction2.prototype.schedule = function(state, delay2) {
if (delay2 === void 0) {
delay2 = 0;
}
if (delay2 > 0) {
return _super.prototype.schedule.call(this, state, delay2);
}
this.delay = delay2;
this.state = state;
this.scheduler.flush(this);
return this;
};
QueueAction2.prototype.execute = function(state, delay2) {
return delay2 > 0 || this.closed ? _super.prototype.execute.call(this, state, delay2) : this._execute(state, delay2);
};
QueueAction2.prototype.requestAsyncId = function(scheduler, id, delay2) {
if (delay2 === void 0) {
delay2 = 0;
}
if (delay2 != null && delay2 > 0 || delay2 == null && this.delay > 0) {
return _super.prototype.requestAsyncId.call(this, scheduler, id, delay2);
}
scheduler.flush(this);
return 0;
};
return QueueAction2;
})(AsyncAction);
// node_modules/rxjs/dist/esm5/internal/scheduler/QueueScheduler.js
var QueueScheduler = (function(_super) {
__extends(QueueScheduler2, _super);
function QueueScheduler2() {
return _super !== null && _super.apply(this, arguments) || this;
}
return QueueScheduler2;
})(AsyncScheduler);
// node_modules/rxjs/dist/esm5/internal/scheduler/queue.js
var queueScheduler = new QueueScheduler(QueueAction);
var queue = queueScheduler;
// node_modules/rxjs/dist/esm5/internal/scheduler/AnimationFrameAction.js
var AnimationFrameAction = (function(_super) {
__extends(AnimationFrameAction2, _super);
function AnimationFrameAction2(scheduler, work) {
var _this = _super.call(this, scheduler, work) || this;
_this.scheduler = scheduler;
_this.work = work;
return _this;
}
AnimationFrameAction2.prototype.requestAsyncId = function(scheduler, id, delay2) {
if (delay2 === void 0) {
delay2 = 0;
}
if (delay2 !== null && delay2 > 0) {
return _super.prototype.requestAsyncId.call(this, scheduler, id, delay2);
}
scheduler.actions.push(this);
return scheduler._scheduled || (scheduler._scheduled = animationFrameProvider.requestAnimationFrame(function() {
return scheduler.flush(void 0);
}));
};
AnimationFrameAction2.prototype.recycleAsyncId = function(scheduler, id, delay2) {
var _a;
if (delay2 === void 0) {
delay2 = 0;
}
if (delay2 != null ? delay2 > 0 : this.delay > 0) {
return _super.prototype.recycleAsyncId.call(this, scheduler, id, delay2);
}
var actions = scheduler.actions;
if (id != null && id === scheduler._scheduled && ((_a = actions[actions.length - 1]) === null || _a === void 0 ? void 0 : _a.id) !== id) {
animationFrameProvider.cancelAnimationFrame(id);
scheduler._scheduled = void 0;
}
return void 0;
};
return AnimationFrameAction2;
})(AsyncAction);
// node_modules/rxjs/dist/esm5/internal/scheduler/AnimationFrameScheduler.js
var AnimationFrameScheduler = (function(_super) {
__extends(AnimationFrameScheduler2, _super);
function AnimationFrameScheduler2() {
return _super !== null && _super.apply(this, arguments) || this;
}
AnimationFrameScheduler2.prototype.flush = function(action) {
this._active = true;
var flushId;
if (action) {
flushId = action.id;
} else {
flushId = this._scheduled;
this._scheduled = void 0;
}
var actions = this.actions;
var error;
action = action || actions.shift();
do {
if (error = action.execute(action.state, action.delay)) {
break;
}
} while ((action = actions[0]) && action.id === flushId && actions.shift());
this._active = false;
if (error) {
while ((action = actions[0]) && action.id === flushId && actions.shift()) {
action.unsubscribe();
}
throw error;
}
};
return AnimationFrameScheduler2;
})(AsyncScheduler);
// node_modules/rxjs/dist/esm5/internal/scheduler/animationFrame.js
var animationFrameScheduler = new AnimationFrameScheduler(AnimationFrameAction);
var animationFrame = animationFrameScheduler;
// node_modules/rxjs/dist/esm5/internal/scheduler/VirtualTimeScheduler.js
var VirtualTimeScheduler = (function(_super) {
__extends(VirtualTimeScheduler2, _super);
function VirtualTimeScheduler2(schedulerActionCtor, maxFrames) {
if (schedulerActionCtor === void 0) {
schedulerActionCtor = VirtualAction;
}
if (maxFrames === void 0) {
maxFrames = Infinity;
}
var _this = _super.call(this, schedulerActionCtor, function() {
return _this.frame;
}) || this;
_this.maxFrames = maxFrames;
_this.frame = 0;
_this.index = -1;
return _this;
}
VirtualTimeScheduler2.prototype.flush = function() {
var _a = this, actions = _a.actions, maxFrames = _a.maxFrames;
var error;
var action;
while ((action = actions[0]) && action.delay <= maxFrames) {
actions.shift();
this.frame = action.delay;
if (error = action.execute(action.state, action.delay)) {
break;
}
}
if (error) {
while (action = actions.shift()) {
action.unsubscribe();
}
throw error;
}
};
VirtualTimeScheduler2.frameTimeFactor = 10;
return VirtualTimeScheduler2;
})(AsyncScheduler);
var VirtualAction = (function(_super) {
__extends(VirtualAction2, _super);
function VirtualAction2(scheduler, work, index) {
if (index === void 0) {
index = scheduler.index += 1;
}
var _this = _super.call(this, scheduler, work) || this;
_this.scheduler = scheduler;
_this.work = work;
_this.index = index;
_this.active = true;
_this.index = scheduler.index = index;
return _this;
}
VirtualAction2.prototype.schedule = function(state, delay2) {
if (delay2 === void 0) {
delay2 = 0;
}
if (Number.isFinite(delay2)) {
if (!this.id) {
return _super.prototype.schedule.call(this, state, delay2);
}
this.active = false;
var action = new VirtualAction2(this.scheduler, this.work);
this.add(action);
return action.schedule(state, delay2);
} else {
return Subscription.EMPTY;
}
};
VirtualAction2.prototype.requestAsyncId = function(scheduler, id, delay2) {
if (delay2 === void 0) {
delay2 = 0;
}
this.delay = scheduler.frame + delay2;
var actions = scheduler.actions;
actions.push(this);
actions.sort(VirtualAction2.sortActions);
return 1;
};
VirtualAction2.prototype.recycleAsyncId = function(scheduler, id, delay2) {
if (delay2 === void 0) {
delay2 = 0;
}
return void 0;
};
VirtualAction2.prototype._execute = function(state, delay2) {
if (this.active === true) {
return _super.prototype._execute.call(this, state, delay2);
}
};
VirtualAction2.sortActions = function(a, b) {
if (a.delay === b.delay) {
if (a.index === b.index) {
return 0;
} else if (a.index > b.index) {
return 1;
} else {
return -1;
}
} else if (a.delay > b.delay) {
return 1;
} else {
return -1;
}
};
return VirtualAction2;
})(AsyncAction);
// node_modules/rxjs/dist/esm5/internal/util/isObservable.js
function isObservable(obj) {
return !!obj && (obj instanceof Observable || isFunction(obj.lift) && isFunction(obj.subscribe));
}
// node_modules/rxjs/dist/esm5/internal/lastValueFrom.js
function lastValueFrom(source, config2) {
var hasConfig = typeof config2 === "object";
return new Promise(function(resolve, reject) {
var _hasValue = false;
var _value;
source.subscribe({
next: function(value) {
_value = value;
_hasValue = true;
},
error: reject,
complete: function() {
if (_hasValue) {
resolve(_value);
} else if (hasConfig) {
resolve(config2.defaultValue);
} else {
reject(new EmptyError());
}
}
});
});
}
// node_modules/rxjs/dist/esm5/internal/firstValueFrom.js
function firstValueFrom(source, config2) {
var hasConfig = typeof config2 === "object";
return new Promise(function(resolve, reject) {
var subscriber = new SafeSubscriber({
next: function(value) {
resolve(value);
subscriber.unsubscribe();
},
error: reject,
complete: function() {
if (hasConfig) {
resolve(config2.defaultValue);
} else {
reject(new EmptyError());
}
}
});
source.subscribe(subscriber);
});
}
// node_modules/rxjs/dist/esm5/internal/observable/bindCallbackInternals.js
function bindCallbackInternals(isNodeStyle, callbackFunc, resultSelector, scheduler) {
if (resultSelector) {
if (isScheduler(resultSelector)) {
scheduler = resultSelector;
} else {
return function() {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
return bindCallbackInternals(isNodeStyle, callbackFunc, scheduler).apply(this, args).pipe(mapOneOrManyArgs(resultSelector));
};
}
}
if (scheduler) {
return function() {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
return bindCallbackInternals(isNodeStyle, callbackFunc).apply(this, args).pipe(subscribeOn(scheduler), observeOn(scheduler));
};
}
return function() {
var _this = this;
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
var subject = new AsyncSubject();
var uninitialized = true;
return new Observable(function(subscriber) {
var subs = subject.subscribe(subscriber);
if (uninitialized) {
uninitialized = false;
var isAsync_1 = false;
var isComplete_1 = false;
callbackFunc.apply(_this, __spreadArray(__spreadArray([], __read(args)), [
function() {
var results = [];
for (var _i2 = 0; _i2 < arguments.length; _i2++) {
results[_i2] = arguments[_i2];
}
if (isNodeStyle) {
var err = results.shift();
if (err != null) {
subject.error(err);
return;
}
}
subject.next(1 < results.length ? results : results[0]);
isComplete_1 = true;
if (isAsync_1) {
subject.complete();
}
}
]));
if (isComplete_1) {
subject.complete();
}
isAsync_1 = true;
}
return subs;
});
};
}
// node_modules/rxjs/dist/esm5/internal/observable/bindCallback.js
function bindCallback(callbackFunc, resultSelector, scheduler) {
return bindCallbackInternals(false, callbackFunc, resultSelector, scheduler);
}
// node_modules/rxjs/dist/esm5/internal/observable/bindNodeCallback.js
function bindNodeCallback(callbackFunc, resultSelector, scheduler) {
return bindCallbackInternals(true, callbackFunc, resultSelector, scheduler);
}
// node_modules/rxjs/dist/esm5/internal/observable/defer.js
function defer(observableFactory) {
return new Observable(function(subscriber) {
innerFrom(observableFactory()).subscribe(subscriber);
});
}
// node_modules/rxjs/dist/esm5/internal/observable/connectable.js
var DEFAULT_CONFIG = {
connector: function() {
return new Subject();
},
resetOnDisconnect: true
};
function connectable(source, config2) {
if (config2 === void 0) {
config2 = DEFAULT_CONFIG;
}
var connection = null;
var connector = config2.connector, _a = config2.resetOnDisconnect, resetOnDisconnect = _a === void 0 ? true : _a;
var subject = connector();
var result = new Observable(function(subscriber) {
return subject.subscribe(subscriber);
});
result.connect = function() {
if (!connection || connection.closed) {
connection = defer(function() {
return source;
}).subscribe(subject);
if (resetOnDisconnect) {
connection.add(function() {
return subject = connector();
});
}
}
return connection;
};
return result;
}
// node_modules/rxjs/dist/esm5/internal/observable/forkJoin.js
function forkJoin() {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
var resultSelector = popResultSelector(args);
var _a = argsArgArrayOrObject(args), sources = _a.args, keys = _a.keys;
var result = new Observable(function(subscriber) {
var length = sources.length;
if (!length) {
subscriber.complete();
return;
}
var values = new Array(length);
var remainingCompletions = length;
var remainingEmissions = length;
var _loop_1 = function(sourceIndex2) {
var hasValue = false;
innerFrom(sources[sourceIndex2]).subscribe(createOperatorSubscriber(subscriber, function(value) {
if (!hasValue) {
hasValue = true;
remainingEmissions--;
}
values[sourceIndex2] = value;
}, function() {
return remainingCompletions--;
}, void 0, function() {
if (!remainingCompletions || !hasValue) {
if (!remainingEmissions) {
subscriber.next(keys ? createObject(keys, values) : values);
}
subscriber.complete();
}
}));
};
for (var sourceIndex = 0; sourceIndex < length; sourceIndex++) {
_loop_1(sourceIndex);
}
});
return resultSelector ? result.pipe(mapOneOrManyArgs(resultSelector)) : result;
}
// node_modules/rxjs/dist/esm5/internal/observable/fromEvent.js
var nodeEventEmitterMethods = ["addListener", "removeListener"];
var eventTargetMethods = ["addEventListener", "removeEventListener"];
var jqueryMethods = ["on", "off"];
function fromEvent(target, eventName, options, resultSelector) {
if (isFunction(options)) {
resultSelector = options;
options = void 0;
}
if (resultSelector) {
return fromEvent(target, eventName, options).pipe(mapOneOrManyArgs(resultSelector));
}
var _a = __read(isEventTarget(target) ? eventTargetMethods.map(function(methodName) {
return function(handler) {
return target[methodName](eventName, handler, options);
};
}) : isNodeStyleEventEmitter(target) ? nodeEventEmitterMethods.map(toCommonHandlerRegistry(target, eventName)) : isJQueryStyleEventEmitter(target) ? jqueryMethods.map(toCommonHandlerRegistry(target, eventName)) : [], 2), add = _a[0], remove = _a[1];
if (!add) {
if (isArrayLike(target)) {
return mergeMap(function(subTarget) {
return fromEvent(subTarget, eventName, options);
})(innerFrom(target));
}
}
if (!add) {
throw new TypeError("Invalid event target");
}
return new Observable(function(subscriber) {
var handler = function() {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
return subscriber.next(1 < args.length ? args : args[0]);
};
add(handler);
return function() {
return remove(handler);
};
});
}
function toCommonHandlerRegistry(target, eventName) {
return function(methodName) {
return function(handler) {
return target[methodName](eventName, handler);
};
};
}
function isNodeStyleEventEmitter(target) {
return isFunction(target.addListener) && isFunction(target.removeListener);
}
function isJQueryStyleEventEmitter(target) {
return isFunction(target.on) && isFunction(target.off);
}
function isEventTarget(target) {
return isFunction(target.addEventListener) && isFunction(target.removeEventListener);
}
// node_modules/rxjs/dist/esm5/internal/observable/fromEventPattern.js
function fromEventPattern(addHandler, removeHandler, resultSelector) {
if (resultSelector) {
return fromEventPattern(addHandler, removeHandler).pipe(mapOneOrManyArgs(resultSelector));
}
return new Observable(function(subscriber) {
var handler = function() {
var e = [];
for (var _i = 0; _i < arguments.length; _i++) {
e[_i] = arguments[_i];
}
return subscriber.next(e.length === 1 ? e[0] : e);
};
var retValue = addHandler(handler);
return isFunction(removeHandler) ? function() {
return removeHandler(handler, retValue);
} : void 0;
});
}
// node_modules/rxjs/dist/esm5/internal/observable/generate.js
function generate(initialStateOrOptions, condition, iterate, resultSelectorOrScheduler, scheduler) {
var _a, _b;
var resultSelector;
var initialState;
if (arguments.length === 1) {
_a = initialStateOrOptions, initialState = _a.initialState, condition = _a.condition, iterate = _a.iterate, _b = _a.resultSelector, resultSelector = _b === void 0 ? identity : _b, scheduler = _a.scheduler;
} else {
initialState = initialStateOrOptions;
if (!resultSelectorOrScheduler || isScheduler(resultSelectorOrScheduler)) {
resultSelector = identity;
scheduler = resultSelectorOrScheduler;
} else {
resultSelector = resultSelectorOrScheduler;
}
}
function gen() {
var state;
return __generator(this, function(_a2) {
switch (_a2.label) {
case 0:
state = initialState;
_a2.label = 1;
case 1:
if (!(!condition || condition(state))) return [3, 4];
return [4, resultSelector(state)];
case 2:
_a2.sent();
_a2.label = 3;
case 3:
state = iterate(state);
return [3, 1];
case 4:
return [2];
}
});
}
return defer(scheduler ? function() {
return scheduleIterable(gen(), scheduler);
} : gen);
}
// node_modules/rxjs/dist/esm5/internal/observable/iif.js
function iif(condition, trueResult, falseResult) {
return defer(function() {
return condition() ? trueResult : falseResult;
});
}
// node_modules/rxjs/dist/esm5/internal/observable/merge.js
function merge() {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
var scheduler = popScheduler(args);
var concurrent = popNumber(args, Infinity);
var sources = args;
return !sources.length ? EMPTY : sources.length === 1 ? innerFrom(sources[0]) : mergeAll(concurrent)(from(sources, scheduler));
}
// node_modules/rxjs/dist/esm5/internal/observable/never.js
var NEVER = new Observable(noop);
function never() {
return NEVER;
}
// node_modules/rxjs/dist/esm5/internal/observable/pairs.js
function pairs(obj, scheduler) {
return from(Object.entries(obj), scheduler);
}
// node_modules/rxjs/dist/esm5/internal/observable/partition.js
function partition(source, predicate, thisArg) {
return [filter(predicate, thisArg)(innerFrom(source)), filter(not(predicate, thisArg))(innerFrom(source))];
}
// node_modules/rxjs/dist/esm5/internal/observable/range.js
function range(start, count2, scheduler) {
if (count2 == null) {
count2 = start;
start = 0;
}
if (count2 <= 0) {
return EMPTY;
}
var end = count2 + start;
return new Observable(scheduler ? function(subscriber) {
var n = start;
return scheduler.schedule(function() {
if (n < end) {
subscriber.next(n++);
this.schedule();
} else {
subscriber.complete();
}
});
} : function(subscriber) {
var n = start;
while (n < end && !subscriber.closed) {
subscriber.next(n++);
}
subscriber.complete();
});
}
// node_modules/rxjs/dist/esm5/internal/observable/using.js
function using(resourceFactory, observableFactory) {
return new Observable(function(subscriber) {
var resource = resourceFactory();
var result = observableFactory(resource);
var source = result ? innerFrom(result) : EMPTY;
source.subscribe(subscriber);
return function() {
if (resource) {
resource.unsubscribe();
}
};
});
}
export {
animationFrames,
asapScheduler,
asap,
queueScheduler,
queue,
animationFrameScheduler,
animationFrame,
VirtualTimeScheduler,
VirtualAction,
isObservable,
lastValueFrom,
firstValueFrom,
bindCallback,
bindNodeCallback,
defer,
connectable,
forkJoin,
fromEvent,
fromEventPattern,
generate,
iif,
merge,
NEVER,
never,
pairs,
partition,
range,
using
};
//# sourceMappingURL=chunk-HWYXSU2G.js.map

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,30 @@
import {
__read,
__spreadArray,
argsOrArgArray,
filter,
not,
raceWith
} from "./chunk-MARUHEWW.js";
// node_modules/rxjs/dist/esm5/internal/operators/partition.js
function partition(predicate, thisArg) {
return function(source) {
return [filter(predicate, thisArg)(source), filter(not(predicate, thisArg))(source)];
};
}
// node_modules/rxjs/dist/esm5/internal/operators/race.js
function race() {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
return raceWith.apply(void 0, __spreadArray([], __read(argsOrArgArray(args))));
}
export {
partition,
race
};
//# sourceMappingURL=chunk-JRFR6BLO.js.map

View File

@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../../../../../../node_modules/rxjs/dist/esm5/internal/operators/partition.js", "../../../../../../node_modules/rxjs/dist/esm5/internal/operators/race.js"],
"sourcesContent": ["import { not } from '../util/not';\nimport { filter } from './filter';\nexport function partition(predicate, thisArg) {\n return function (source) {\n return [filter(predicate, thisArg)(source), filter(not(predicate, thisArg))(source)];\n };\n}\n", "import { __read, __spreadArray } from \"tslib\";\nimport { argsOrArgArray } from '../util/argsOrArgArray';\nimport { raceWith } from './raceWith';\nexport function race() {\n var args = [];\n for (var _i = 0; _i < arguments.length; _i++) {\n args[_i] = arguments[_i];\n }\n return raceWith.apply(void 0, __spreadArray([], __read(argsOrArgArray(args))));\n}\n"],
"mappings": ";;;;;;;;;;AAEO,SAAS,UAAU,WAAW,SAAS;AAC1C,SAAO,SAAU,QAAQ;AACrB,WAAO,CAAC,OAAO,WAAW,OAAO,EAAE,MAAM,GAAG,OAAO,IAAI,WAAW,OAAO,CAAC,EAAE,MAAM,CAAC;AAAA,EACvF;AACJ;;;ACHO,SAAS,OAAO;AACnB,MAAI,OAAO,CAAC;AACZ,WAAS,KAAK,GAAG,KAAK,UAAU,QAAQ,MAAM;AAC1C,SAAK,EAAE,IAAI,UAAU,EAAE;AAAA,EAC3B;AACA,SAAO,SAAS,MAAM,QAAQ,cAAc,CAAC,GAAG,OAAO,eAAe,IAAI,CAAC,CAAC,CAAC;AACjF;",
"names": []
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,29 @@
// node_modules/@angular/common/fesm2022/xhr.mjs
function parseCookieValue(cookieStr, name) {
name = encodeURIComponent(name);
for (const cookie of cookieStr.split(";")) {
const eqIndex = cookie.indexOf("=");
const [cookieName, cookieValue] = eqIndex == -1 ? [cookie, ""] : [cookie.slice(0, eqIndex), cookie.slice(eqIndex + 1)];
if (cookieName.trim() === name) {
return decodeURIComponent(cookieValue);
}
}
return null;
}
var XhrFactory = class {
};
export {
parseCookieValue,
XhrFactory
};
/*! Bundled license information:
@angular/common/fesm2022/xhr.mjs:
(**
* @license Angular v20.2.4
* (c) 2010-2025 Google LLC. https://angular.io/
* License: MIT
*)
*/
//# sourceMappingURL=chunk-OUSM42MY.js.map

View File

@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../../../../../../node_modules/@angular/common/fesm2022/xhr.mjs"],
"sourcesContent": ["/**\n * @license Angular v20.2.4\n * (c) 2010-2025 Google LLC. https://angular.io/\n * License: MIT\n */\n\nfunction parseCookieValue(cookieStr, name) {\n name = encodeURIComponent(name);\n for (const cookie of cookieStr.split(';')) {\n const eqIndex = cookie.indexOf('=');\n const [cookieName, cookieValue] = eqIndex == -1 ? [cookie, ''] : [cookie.slice(0, eqIndex), cookie.slice(eqIndex + 1)];\n if (cookieName.trim() === name) {\n return decodeURIComponent(cookieValue);\n }\n }\n return null;\n}\n\n/**\n * A wrapper around the `XMLHttpRequest` constructor.\n *\n * @publicApi\n */\nclass XhrFactory {\n}\n\nexport { XhrFactory, parseCookieValue };\n\n"],
"mappings": ";AAMA,SAAS,iBAAiB,WAAW,MAAM;AACvC,SAAO,mBAAmB,IAAI;AAC9B,aAAW,UAAU,UAAU,MAAM,GAAG,GAAG;AACvC,UAAM,UAAU,OAAO,QAAQ,GAAG;AAClC,UAAM,CAAC,YAAY,WAAW,IAAI,WAAW,KAAK,CAAC,QAAQ,EAAE,IAAI,CAAC,OAAO,MAAM,GAAG,OAAO,GAAG,OAAO,MAAM,UAAU,CAAC,CAAC;AACrH,QAAI,WAAW,KAAK,MAAM,MAAM;AAC5B,aAAO,mBAAmB,WAAW;AAAA,IACzC;AAAA,EACJ;AACA,SAAO;AACX;AAOA,IAAM,aAAN,MAAiB;AACjB;",
"names": []
}

View File

@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@ -0,0 +1,353 @@
import {
NEVER,
VirtualAction,
VirtualTimeScheduler,
animationFrame,
animationFrameScheduler,
animationFrames,
asap,
asapScheduler,
bindCallback,
bindNodeCallback,
connectable,
defer,
firstValueFrom,
forkJoin,
fromEvent,
fromEventPattern,
generate,
iif,
isObservable,
lastValueFrom,
merge,
never,
pairs,
partition,
queue,
queueScheduler,
range,
using
} from "./chunk-HWYXSU2G.js";
import {
ArgumentOutOfRangeError,
AsyncSubject,
BehaviorSubject,
ConnectableObservable,
EMPTY,
EmptyError,
NotFoundError,
Notification,
NotificationKind,
ObjectUnsubscribedError,
Observable,
ReplaySubject,
Scheduler,
SequenceError,
Subject,
Subscriber,
Subscription,
TimeoutError,
UnsubscriptionError,
async,
asyncScheduler,
audit,
auditTime,
buffer,
bufferCount,
bufferTime,
bufferToggle,
bufferWhen,
catchError,
combineAll,
combineLatest,
combineLatestAll,
combineLatestWith,
concat,
concatAll,
concatMap,
concatMapTo,
concatWith,
config,
connect,
count,
debounce,
debounceTime,
defaultIfEmpty,
delay,
delayWhen,
dematerialize,
distinct,
distinctUntilChanged,
distinctUntilKeyChanged,
elementAt,
empty,
endWith,
every,
exhaust,
exhaustAll,
exhaustMap,
expand,
filter,
finalize,
find,
findIndex,
first,
flatMap,
from,
groupBy,
identity,
ignoreElements,
interval,
isEmpty,
last,
map,
mapTo,
materialize,
max,
mergeAll,
mergeMap,
mergeMapTo,
mergeScan,
mergeWith,
min,
multicast,
noop,
observable,
observeOn,
of,
onErrorResumeNext,
onErrorResumeNextWith,
pairwise,
pipe,
pluck,
publish,
publishBehavior,
publishLast,
publishReplay,
race,
raceWith,
reduce,
refCount,
repeat,
repeatWhen,
retry,
retryWhen,
sample,
sampleTime,
scan,
scheduled,
sequenceEqual,
share,
shareReplay,
single,
skip,
skipLast,
skipUntil,
skipWhile,
startWith,
subscribeOn,
switchAll,
switchMap,
switchMapTo,
switchScan,
take,
takeLast,
takeUntil,
takeWhile,
tap,
throttle,
throttleTime,
throwError,
throwIfEmpty,
timeInterval,
timeout,
timeoutWith,
timer,
timestamp,
toArray,
window,
windowCount,
windowTime,
windowToggle,
windowWhen,
withLatestFrom,
zip,
zipAll,
zipWith
} from "./chunk-MARUHEWW.js";
import "./chunk-GOMI4DH3.js";
export {
ArgumentOutOfRangeError,
AsyncSubject,
BehaviorSubject,
ConnectableObservable,
EMPTY,
EmptyError,
NEVER,
NotFoundError,
Notification,
NotificationKind,
ObjectUnsubscribedError,
Observable,
ReplaySubject,
Scheduler,
SequenceError,
Subject,
Subscriber,
Subscription,
TimeoutError,
UnsubscriptionError,
VirtualAction,
VirtualTimeScheduler,
animationFrame,
animationFrameScheduler,
animationFrames,
asap,
asapScheduler,
async,
asyncScheduler,
audit,
auditTime,
bindCallback,
bindNodeCallback,
buffer,
bufferCount,
bufferTime,
bufferToggle,
bufferWhen,
catchError,
combineAll,
combineLatest,
combineLatestAll,
combineLatestWith,
concat,
concatAll,
concatMap,
concatMapTo,
concatWith,
config,
connect,
connectable,
count,
debounce,
debounceTime,
defaultIfEmpty,
defer,
delay,
delayWhen,
dematerialize,
distinct,
distinctUntilChanged,
distinctUntilKeyChanged,
elementAt,
empty,
endWith,
every,
exhaust,
exhaustAll,
exhaustMap,
expand,
filter,
finalize,
find,
findIndex,
first,
firstValueFrom,
flatMap,
forkJoin,
from,
fromEvent,
fromEventPattern,
generate,
groupBy,
identity,
ignoreElements,
iif,
interval,
isEmpty,
isObservable,
last,
lastValueFrom,
map,
mapTo,
materialize,
max,
merge,
mergeAll,
mergeMap,
mergeMapTo,
mergeScan,
mergeWith,
min,
multicast,
never,
noop,
observable,
observeOn,
of,
onErrorResumeNext,
onErrorResumeNextWith,
pairs,
pairwise,
partition,
pipe,
pluck,
publish,
publishBehavior,
publishLast,
publishReplay,
queue,
queueScheduler,
race,
raceWith,
range,
reduce,
refCount,
repeat,
repeatWhen,
retry,
retryWhen,
sample,
sampleTime,
scan,
scheduled,
sequenceEqual,
share,
shareReplay,
single,
skip,
skipLast,
skipUntil,
skipWhile,
startWith,
subscribeOn,
switchAll,
switchMap,
switchMapTo,
switchScan,
take,
takeLast,
takeUntil,
takeWhile,
tap,
throttle,
throttleTime,
throwError,
throwIfEmpty,
timeInterval,
timeout,
timeoutWith,
timer,
timestamp,
toArray,
using,
window,
windowCount,
windowTime,
windowToggle,
windowWhen,
withLatestFrom,
zip,
zipAll,
zipWith
};

View File

@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

View File

@ -0,0 +1,233 @@
import {
partition,
race
} from "./chunk-JRFR6BLO.js";
import {
audit,
auditTime,
buffer,
bufferCount,
bufferTime,
bufferToggle,
bufferWhen,
catchError,
combineAll,
combineLatest2 as combineLatest,
combineLatestAll,
combineLatestWith,
concat2 as concat,
concatAll,
concatMap,
concatMapTo,
concatWith,
connect,
count,
debounce,
debounceTime,
defaultIfEmpty,
delay,
delayWhen,
dematerialize,
distinct,
distinctUntilChanged,
distinctUntilKeyChanged,
elementAt,
endWith,
every,
exhaust,
exhaustAll,
exhaustMap,
expand,
filter,
finalize,
find,
findIndex,
first,
flatMap,
groupBy,
ignoreElements,
isEmpty,
last,
map,
mapTo,
materialize,
max,
merge,
mergeAll,
mergeMap,
mergeMapTo,
mergeScan,
mergeWith,
min,
multicast,
observeOn,
onErrorResumeNext2 as onErrorResumeNext,
pairwise,
pluck,
publish,
publishBehavior,
publishLast,
publishReplay,
raceWith,
reduce,
refCount,
repeat,
repeatWhen,
retry,
retryWhen,
sample,
sampleTime,
scan,
sequenceEqual,
share,
shareReplay,
single,
skip,
skipLast,
skipUntil,
skipWhile,
startWith,
subscribeOn,
switchAll,
switchMap,
switchMapTo,
switchScan,
take,
takeLast,
takeUntil,
takeWhile,
tap,
throttle,
throttleTime,
throwIfEmpty,
timeInterval,
timeout,
timeoutWith,
timestamp,
toArray,
window,
windowCount,
windowTime,
windowToggle,
windowWhen,
withLatestFrom,
zip2 as zip,
zipAll,
zipWith
} from "./chunk-MARUHEWW.js";
import "./chunk-GOMI4DH3.js";
export {
audit,
auditTime,
buffer,
bufferCount,
bufferTime,
bufferToggle,
bufferWhen,
catchError,
combineAll,
combineLatest,
combineLatestAll,
combineLatestWith,
concat,
concatAll,
concatMap,
concatMapTo,
concatWith,
connect,
count,
debounce,
debounceTime,
defaultIfEmpty,
delay,
delayWhen,
dematerialize,
distinct,
distinctUntilChanged,
distinctUntilKeyChanged,
elementAt,
endWith,
every,
exhaust,
exhaustAll,
exhaustMap,
expand,
filter,
finalize,
find,
findIndex,
first,
flatMap,
groupBy,
ignoreElements,
isEmpty,
last,
map,
mapTo,
materialize,
max,
merge,
mergeAll,
mergeMap,
mergeMapTo,
mergeScan,
mergeWith,
min,
multicast,
observeOn,
onErrorResumeNext,
pairwise,
partition,
pluck,
publish,
publishBehavior,
publishLast,
publishReplay,
race,
raceWith,
reduce,
refCount,
repeat,
repeatWhen,
retry,
retryWhen,
sample,
sampleTime,
scan,
sequenceEqual,
share,
shareReplay,
single,
skip,
skipLast,
skipUntil,
skipWhile,
startWith,
subscribeOn,
switchAll,
switchMap,
switchMapTo,
switchScan,
take,
takeLast,
takeUntil,
takeWhile,
tap,
throttle,
throttleTime,
throwIfEmpty,
timeInterval,
timeout,
timeoutWith,
timestamp,
toArray,
window,
windowCount,
windowTime,
windowToggle,
windowWhen,
withLatestFrom,
zip,
zipAll,
zipWith
};

View File

@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

View File

@ -0,0 +1,8 @@
{
"hash": "bd8fc5b3",
"configHash": "d5bc65c8",
"lockfileHash": "c86d7ad1",
"browserHash": "5244fe32",
"optimized": {},
"chunks": {}
}

View File

@ -0,0 +1,3 @@
{
"type": "module"
}

19
.dockerignore Normal file
View File

@ -0,0 +1,19 @@
node_modules
.angular
.tmp
tmp
npm-debug.log
Dockerfile*
.dockerignore
.git
.gitignore
dist
# Persist DB separately; do not copy local DB into build context
db/*.db
db/*.sqlite*
db/*.db-journal
db/*.sqlite-wal
db/*.sqlite-shm
# OS junk
.DS_Store
Thumbs.db

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Local API keys/config (do not commit)
/assets/config.local.js
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Temp downloads
tmp/

56
Dockerfile Normal file
View File

@ -0,0 +1,56 @@
# syntax=docker/dockerfile:1
# -------------------- Build stage --------------------
FROM node:20-bullseye AS builder
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm ci
# Copy sources
COPY . .
# Build Angular app (outputs to ./dist)
RUN npx ng build --configuration=production
# Prune dev dependencies to keep only production deps for runtime copy
RUN npm prune --omit=dev
# -------------------- Runtime stage --------------------
FROM node:20-bullseye-slim AS runner
ENV NODE_ENV=production \
YOUTUBE_DL_SKIP_PYTHON_CHECK=1
WORKDIR /app
# Create required runtime directories
RUN mkdir -p /app/db /app/tmp/downloads
# youtube-dl-exec (yt-dlp) requires Python at runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 python-is-python3 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Copy runtime server and built frontend
COPY --from=builder /app/server ./server
COPY --from=builder /app/dist ./dist
# Copy only the DB schema; the actual DB file will be created on first run
COPY --from=builder /app/db/schema.sql ./db/schema.sql
# Copy production dependencies from builder
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
# Copy config
COPY --from=builder /app/assets/config.local.js ./assets/config.local.js
# Après la ligne COPY pour config.local.js
RUN ls -la /app/assets/ && \
echo "Contenu de config.local.js:" && \
cat /app/assets/config.local.js || echo "Fichier config.local.js non trouvé"
# Expose API/web port
EXPOSE 4000
# Start the API server (serves the Angular build from ./dist)
CMD ["node", "./server/index.mjs"]

198
README.md Normal file
View File

@ -0,0 +1,198 @@
# NewTube — Agrégateur multiplateformes (Angular)
NewTube est une application Angular légère permettant de découvrir des vidéos « tendances » et deffectuer des recherches sur plusieurs plateformes à partir dune seule interface.
Providers supportés à ce jour: YouTube, Dailymotion, Twitch, PeerTube (instances configurables), Odysee (via proxy), Rumble (via proxy, clé requise).
---
## Sommaire
- Aperçu & objectifs
- Stack & patterns
- Prérequis
- Installation & lancement
- Configuration des providers (clés/API)
- Proxies de développement
- Utilisation rapide
- État des fonctionnalités (checklist)
- Roadmap / TODO (priorités)
---
## Aperçu & objectifs
Lobjectif de NewTube est doffrir une expérience unifiée pour explorer les contenus vidéo sans basculer entre plusieurs sites. Lapp expose un sélecteur de provider et une zone de recherche, et propose une page daccueil « Tendance ». Les appels réseaux sont préfiltrés par un contrôle centralisé daptitude (« readiness ») afin déviter des erreurs lorsque des clés manquent (YouTube, Twitch, Rumble) ou lorsquun provider nécessite une configuration préalable (PeerTube).
## Stack & patterns
- Angular 20 (standalone components, strict mode)
- Signals (`signal`, `computed`, `effect`) pour la réactivité UI
- RxJS pour la composition dappels HTTP
- Tailwind CSS via CDN pour le style rapide
- Proxy de dev Angular pour contourner le CORS (voir `proxy.conf.json`)
Principes de code:
- Contrôle centralisé de « readiness » dans `src/services/instance.service.ts`
- Un service unique `src/services/youtube-api.service.ts` qui route les appels selon le provider
- Mappers par provider pour convertir les réponses en modèle interne `Video`
- Effets dans les composants (`Home`, `Search`, `Watch`) pour réagir aux changements de provider/région/instance
## Prérequis
- Node.js (version récente LTS)
- Aucune installation globale dAngular CLI nest requise: elle est fournie en dépendance du projet
## Installation & lancement
1. Installer les dépendances
```bash
npm install
```
2. Copier la configuration locale et renseigner vos clés si nécessaire
```bash
cp assets/config.local.example.js assets/config.local.js
# Éditer assets/config.local.js et compléter les clés
```
3. Lancer le serveur de développement
```bash
npm run dev
```
- Par défaut: http://localhost:4200
4. Build de production
```bash
npm run build
```
## Déploiement Docker
Cette app peut tourner dans un unique conteneur Node qui sert lAPI Express et le build Angular (dossier `dist/`).
### Construction de limage
```bash
docker build -t newtube:latest .
```
### Lancement avec docker-compose
```bash
docker compose up -d
```
Par défaut:
- Frontend + API disponibles sur `http://localhost:4000`
- Le build Angular est servi statiquement par `server/index.mjs` depuis `./dist`
- La base SQLite et les téléchargements temporaires sont persistés via des volumes:
- `./db` monté sur `/app/db`
- `./tmp/downloads` monté sur `/app/tmp/downloads`
### Variables denvironnement utiles
- `PORT` (defaut: `4000`)
- `NODE_ENV` (`production` en conteneur)
- `TWITCH_CLIENT_ID`, `TWITCH_CLIENT_SECRET` (pour la recherche Twitch)
- `YOUTUBE_API_KEY` ou `YOUTUBE_API_KEYS` (clé unique ou liste pour la recherche YouTube)
- `YT_CACHE_TTL_MS` (TTL du cache côté serveur pour YouTube)
Notes:
- Les clés front (YouTube) peuvent aussi être définies dans `assets/config.local.js`. En prod Docker, préférez les variables denvironnement côté serveur quand cest pris en charge par le provider.
- Le chemin des cookies dauth côté API sadapte automatiquement: `/api` en production (Docker), `/proxy/api` en dev local.
## Configuration des providers (clés/API)
La configuration locale est lue depuis `assets/config.local.js` (non versionné). Exemple:
```html
<script>
// YouTube (clé Data API v3, restreinte par referer HTTP)
window.YOUTUBE_API_KEY = 'VOTRE_CLE_YOUTUBE';
// Google Gemini (optionnel, pour la synthèse des commentaires sur la page Watch)
// window.GEMINI_API_KEY = 'VOTRE_CLE_GEMINI';
// Twitch (requis pour les appels Helix)
window.TWITCH_CLIENT_ID = 'VOTRE_CLIENT_ID';
window.TWITCH_CLIENT_SECRET = 'VOTRE_CLIENT_SECRET';
// Rumble (requis pour utiliser Rumble dans cette app)
// Certaines API publiques Rumble étant limitées, lapp attend une clé exposée côté navigateur.
window.RUMBLE_API_KEY = 'VOTRE_CLE_RUMBLE';
// Odysee: aucune clé requise (les requêtes passent via un proxy côté dev)
</script>
```
Notes:
- YouTube: restreignez la clé par referer HTTP (ex: `http://localhost:4200/*`) et nactivez que les APIs nécessaires.
- Odysee: aucune clé; appels proxifiés vers `https://api.na-backend.odysee.com/api/v1/proxy`.
- Rumble: clé requise pour activer Trending et Search.
- PeerTube: nécessite la sélection dune instance active (voir entête de lapp).
## Proxies de développement
Le fichier `proxy.conf.json` redirige les requêtes suivantes:
- `/proxy/yt``https://www.googleapis.com`
- `/proxy/dm``https://api.dailymotion.com`
- `/proxy/twitch-api``https://api.twitch.tv`
- `/proxy/twitch-auth``https://id.twitch.tv`
- `/proxy/odysee``https://api.na-backend.odysee.com`
- `/proxy/rumble``https://rumble.com/api/v0`
Ces routes sont utilisées par `YoutubeApiService` pour contourner le CORS en dev.
## Utilisation rapide
- Sélectionnez un provider et (si disponible) une région; pour PeerTube, choisissez linstance.
- Page daccueil: vidéos tendances du provider actif.
- Recherche: résultats selon les capacités du provider (YouTube/Dailymotion: vidéos, Twitch: chaînes, Odysee/Rumble: vidéos).
- Page Watch: bouton « Résumer les commentaires » (Gemini). Le bouton est désactivé si la clé manque.
- Si une clé/instance est manquante, une notice claire saffiche et lapp évite les appels réseau inutiles.
## État des fonctionnalités (checklist)
- [X] Contrôle centralisé de readiness par provider (`InstanceService.getProviderReadiness`)
- [X] Gating readiness dans `Home`/`Search` pour éviter les appels et afficher des notices
- [X] Intégration Odysee (Trending + Search) via proxy `/proxy/odysee` (+ mapping)
- [X] Intégration Rumble (Trending + Search) via proxy `/proxy/rumble` (+ mapping, clé requise)
- [X] Suppression dUtreon
- [X] Proxies vérifiés/ajoutés (YouTube, Dailymotion, Twitch, Odysee, Rumble)
- [X] Gating Gemini dans la page Watch (désactivation bouton + message si clé manquante)
- [X] Messages spécifiques (YouTube: clé invalide ou referer HTTP)
- [ ] Détails vidéo/Commentaires par provider sur la page Watch (implémentations dédiées)
- [ ] Vérification de disponibilité dinstance PeerTube (reachability check)
## Roadmap / TODO (priorités)
1) [Haute] Notice globale dans lentête quand le provider sélectionné nest pas prêt (readiness banner)
2) [Haute] Tests unitaires pour `InstanceService.getProviderReadiness` et comportements des composants
3) [Moyenne] Finaliser/Renforcer lintégration Rumble (endpoints/documentation officielle, meilleurs mappers)
4) [Moyenne] Reachability check des instances PeerTube (ping + fallback)
5) [Moyenne] Implémenter `getVideoDetails` et récupération des commentaires par provider pour la page Watch
6) [Basse] Option: masquer dans le sélecteur les providers non prêts (ou les griser avec explication)
7) [Basse] Améliorer lUX (avatars réels pour YouTube/Twitch, états vides plus parlants)
---
## Scripts NPM
- `npm run dev` — démarre le serveur de dev Angular (proxy activé)
- `npm run build` — build de production (dist)
- `npm run preview` — sert le build avec configuration production
## Notes de sécurité & bonnes pratiques
- Ne versionnez jamais vos clés. Utilisez `assets/config.local.js` (ignoré par git).
- Pour les clés front (YouTube), appliquez des restrictions de referer HTTP et quotas.
- Les intégrations Odysee/Rumble sont basées sur des endpoints publics et peuvent évoluer; lapp gère les erreurs en douceur et affiche des notices.

56
angular.json Normal file
View File

@ -0,0 +1,56 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "",
"projects": {
"app": {
"projectType": "application",
"root": "",
"sourceRoot": "./",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": {
"base": "./dist",
"browser": "."
},
"browser": "index.tsx",
"tsConfig": "tsconfig.json",
"assets": [
"assets",
"index.css"
]
},
"configurations": {
"production": {
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "app:build:production"
},
"development": {
"buildTarget": "app:build:development"
}
},
"defaultConfiguration": "development"
}
}
}
}
}

View File

@ -0,0 +1,25 @@
// Local (non-versioned) config for API keys and secrets.
// Copy this file to `assets/config.local.js` and fill in your keys.
// IMPORTANT: Restrict keys in their provider consoles (HTTP referrers, APIs enabled).
// YouTube Data API v3 key (browser-safe only if properly restricted by HTTP referrer)
window.YOUTUBE_API_KEY = 'AIzaSyCumEzXNPJuQjpPpxhP2PYdExqRBDVJqRY';
// Optional: Gemini (for comment summarization)
// window.GEMINI_API_KEY = 'PUT_YOUR_GEMINI_API_KEY_HERE';
// Vimeo API Access Token (must have 'public' scope)
window.VIMEO_ACCESS_TOKEN = 'PUT_YOUR_VIMEO_ACCESS_TOKEN_HERE';
// Twitch API Credentials
window.TWITCH_CLIENT_ID = 'PUT_YOUR_TWITCH_CLIENT_ID_HERE';
window.TWITCH_CLIENT_SECRET = 'PUT_YOUR_TWITCH_CLIENT_SECRET_HERE';
// Rumble API key (required for Rumble integration in this app)
// Some Rumble endpoints are not public. This app expects a key surfaced as window.RUMBLE_API_KEY.
// If you don't have one, select another provider in the header.
window.RUMBLE_API_KEY = 'PUT_YOUR_RUMBLE_API_KEY_HERE';
// Odysee: no API key is required when using the built-in proxy.
// You can add other provider keys/tokens here if needed in the future.

BIN
db/newtube.db Normal file

Binary file not shown.

149
db/schema.sql Normal file
View File

@ -0,0 +1,149 @@
-- NewTube User Management Schema (SQLite)
-- Timestamps are ISO8601 (UTC). Use PRAGMA foreign_keys = ON;
PRAGMA foreign_keys = ON;
-- Users & identity -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT UNIQUE,
password_hash TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_login_at TEXT
);
CREATE TABLE IF NOT EXISTS user_preferences (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
language TEXT DEFAULT 'en',
default_provider TEXT,
theme TEXT DEFAULT 'system',
video_quality TEXT DEFAULT 'auto',
region TEXT,
version INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL
);
-- Sessions: refresh tokens are stored hashed; per-device visibility ----------
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
refresh_token_hash TEXT NOT NULL,
user_agent TEXT,
device_info TEXT,
ip_address TEXT,
is_remember INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
last_seen_at TEXT,
expires_at TEXT NOT NULL,
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_lastseen ON sessions(last_seen_at);
-- Login audit ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS login_audit (
id TEXT PRIMARY KEY,
user_id TEXT,
username TEXT,
ip_address TEXT,
user_agent TEXT,
success INTEGER NOT NULL,
reason TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_login_audit_user_time ON login_audit(user_id, created_at DESC);
-- Search history ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS search_history (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
query TEXT NOT NULL,
filters_json TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_search_history_user_time ON search_history(user_id, created_at DESC);
-- Watch history -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS watch_history (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
video_id TEXT NOT NULL,
title TEXT,
watched_at TEXT NOT NULL,
progress_seconds INTEGER DEFAULT 0,
duration_seconds INTEGER DEFAULT 0,
last_position_seconds INTEGER DEFAULT 0,
last_watched_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_watch_history_user_video ON watch_history(user_id, provider, video_id);
CREATE INDEX IF NOT EXISTS idx_watch_history_user_time ON watch_history(user_id, watched_at DESC);
-- Subscriptions & categories -----------------------------------------------
CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(user_id, name)
);
CREATE TABLE IF NOT EXISTS subscriptions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
channel_id TEXT NOT NULL,
channel_name TEXT,
notify INTEGER NOT NULL DEFAULT 0,
subscribed_at TEXT NOT NULL,
UNIQUE(user_id, provider, channel_id)
);
CREATE TABLE IF NOT EXISTS subscription_categories (
subscription_id TEXT NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
category_id TEXT NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
PRIMARY KEY (subscription_id, category_id)
);
CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id);
-- Playlists -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, name)
);
CREATE TABLE IF NOT EXISTS playlist_items (
id TEXT PRIMARY KEY,
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
video_id TEXT NOT NULL,
title TEXT,
added_at TEXT NOT NULL,
position INTEGER NOT NULL,
UNIQUE(playlist_id, provider, video_id)
);
CREATE INDEX IF NOT EXISTS idx_playlist_items_order ON playlist_items(playlist_id, position);
-- Tags (optional) -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(user_id, name)
);
CREATE TABLE IF NOT EXISTS video_tags (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
video_id TEXT NOT NULL,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, provider, video_id, tag_id)
);

21
docker-compose.yml Normal file
View File

@ -0,0 +1,21 @@
version: "3.9"
services:
newtube:
build: .
image: newtube:latest
container_name: newtube
ports:
- "4000:4000"
environment:
NODE_ENV: production
# Optional: configure API server
PORT: 4000
# Optional: secrets for Twitch etc. (if you prefer server env)
# TWITCH_CLIENT_ID: ""
# TWITCH_CLIENT_SECRET: ""
# YOUTUBE_API_KEY: ""
volumes:
# Persist the SQLite database and download temp dir outside the image
- ./db:/app/db
- ./tmp/downloads:/app/tmp/downloads
restart: unless-stopped

412
docs/user-management.md Normal file
View File

@ -0,0 +1,412 @@
# NewTube  User Management (SQLite)
This document defines a simple, secure, and extensible user management module for NewTube, optimized for a private setup with a small user base and a SQLite backend.
## Goals
- Simple: small schema, clear endpoints, easy local setup.
- Secure: strong password hashing, short-lived access token + rotating refresh token in HttpOnly cookie, rate limiting.
- Extensible: flexible provider support, versioned preferences, optional/JSON fields.
- Portable: single SQLite DB, easy migrations and backups; works for Web + Desktop (Electron/Tauri).
## Data Model (SQLite)
Types: TEXT, INTEGER, REAL, BLOB. Timestamps: ISO8601 strings in UTC.
```sql
-- Users & identity ---------------------------------------------------------
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, -- UUID
username TEXT NOT NULL UNIQUE,
email TEXT UNIQUE,
password_hash TEXT NOT NULL, -- Argon2id or bcrypt
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_login_at TEXT
);
CREATE TABLE IF NOT EXISTS user_preferences (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
language TEXT DEFAULT 'en', -- 'fr','en','es', ...
default_provider TEXT, -- 'youtube','twitch','dailymotion','vimeo','peertube','odysee','rumble', ...
theme TEXT DEFAULT 'system', -- 'light','dark','black','system'
video_quality TEXT DEFAULT 'auto', -- '720p','1080p','auto'
region TEXT, -- 'FR','US','CA', ...
version INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL
);
-- Sessions: refresh tokens are stored hashed; per-device visibility ----------
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, -- UUID (session id)
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
refresh_token_hash TEXT NOT NULL,
user_agent TEXT,
device_info TEXT,
ip_address TEXT,
is_remember INTEGER NOT NULL DEFAULT 0, -- remember-me checkbox
created_at TEXT NOT NULL,
last_seen_at TEXT,
expires_at TEXT NOT NULL,
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_lastseen ON sessions(last_seen_at);
-- Login audit (success and failed attempts) ---------------------------------
CREATE TABLE IF NOT EXISTS login_audit (
id TEXT PRIMARY KEY,
user_id TEXT,
username TEXT,
ip_address TEXT,
user_agent TEXT,
success INTEGER NOT NULL,
reason TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_login_audit_user_time ON login_audit(user_id, created_at DESC);
-- Search & Watch History ----------------------------------------------------
CREATE TABLE IF NOT EXISTS search_history (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
query TEXT NOT NULL,
filters_json TEXT, -- { duration:'short', sort:'relevance' }
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_search_history_user_time ON search_history(user_id, created_at DESC);
CREATE TABLE IF NOT EXISTS watch_history (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
video_id TEXT NOT NULL,
title TEXT,
watched_at TEXT NOT NULL,
progress_seconds INTEGER DEFAULT 0,
duration_seconds INTEGER DEFAULT 0,
last_position_seconds INTEGER DEFAULT 0,
last_watched_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_watch_history_user_video ON watch_history(user_id, provider, video_id);
CREATE INDEX IF NOT EXISTS idx_watch_history_user_time ON watch_history(user_id, watched_at DESC);
-- Subscriptions & Categories ------------------------------------------------
CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(user_id, name)
);
CREATE TABLE IF NOT EXISTS subscriptions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
channel_id TEXT NOT NULL,
channel_name TEXT,
notify INTEGER NOT NULL DEFAULT 0,
subscribed_at TEXT NOT NULL,
UNIQUE(user_id, provider, channel_id)
);
CREATE TABLE IF NOT EXISTS subscription_categories (
subscription_id TEXT NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
category_id TEXT NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
PRIMARY KEY (subscription_id, category_id)
);
CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id);
-- Playlists -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, name)
);
CREATE TABLE IF NOT EXISTS playlist_items (
id TEXT PRIMARY KEY,
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
video_id TEXT NOT NULL,
title TEXT,
added_at TEXT NOT NULL,
position INTEGER NOT NULL,
UNIQUE(playlist_id, provider, video_id)
);
CREATE INDEX IF NOT EXISTS idx_playlist_items_order ON playlist_items(playlist_id, position);
-- Personal tags (optional/bonus) -------------------------------------------
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(user_id, name)
);
CREATE TABLE IF NOT EXISTS video_tags (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
video_id TEXT NOT NULL,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, provider, video_id, tag_id)
);
```
## API (REST)  Dev base URL
- Prod base URL: `https://<your-domain>/api`
- Dev via Angular proxy: `http://localhost:4200/proxy/api`
- Add to `proxy.conf.json`:
```json
{
"/proxy/api": {
"target": "http://localhost:4000",
"secure": false,
"changeOrigin": true,
"logLevel": "debug",
"pathRewrite": { "^/proxy/api": "/api" }
}
}
```
You will need to restart `npm run dev` after editing the proxy.
### Auth
- POST `/auth/register` → { username, password, email? }
- POST `/auth/login` → { username, password, rememberMe? }
- POST `/auth/refresh` (cookie) → rotates refresh, returns new access token
- POST `/auth/logout` → { allDevices? }
- GET `/auth/sessions` → list active sessions
- DELETE `/auth/sessions/:id` → revoke a session
Responses typically include: `{ user: {id, username, email}, accessToken, sessionId }` and set a `refreshToken` cookie (HttpOnly, Secure, SameSite=Strict).
### User & Preferences
- GET `/user/me`
- PATCH `/user/me` → { email? }
- GET `/user/preferences`
- PATCH `/user/preferences` → { language?, defaultProvider?, theme?, videoQuality?, region? }
### History
- GET `/user/history/search?limit=&before=`
- POST `/user/history/search` → { query, filters? }
- DELETE `/user/history/search/:id` | `/user/history/search?all=1`
- GET `/user/history/watch?limit=&before=`
- POST `/user/history/watch` → { provider, videoId, title?, watchedAt, progressSeconds?, durationSeconds? }
- PATCH `/user/history/watch/:id` → { progressSeconds?, lastPositionSeconds? }
- DELETE `/user/history/watch/:id` | `/user/history/watch?all=1`
### Subscriptions & Categories
- GET `/user/subscriptions?category=&provider=`
- POST `/user/subscriptions` → { provider, channelId, channelName?, notify? }
- DELETE `/user/subscriptions/:id`
- POST `/user/subscriptions/:id/categories` → { add:[categoryId], remove:[categoryId] }
- GET `/user/categories`
- POST `/user/categories` → { name }
- DELETE `/user/categories/:id`
### Playlists
- GET `/user/playlists`
- POST `/user/playlists` → { name }
- PATCH `/user/playlists/:id` → { name }
- DELETE `/user/playlists/:id`
- GET `/user/playlists/:id/items`
- POST `/user/playlists/:id/items` → { provider, videoId, title?, position? }
- PATCH `/user/playlists/:id/items/reorder` → { items:[{id, position}] }
- DELETE `/user/playlists/:id/items/:itemId`
- Export/Import:
- GET `/user/playlists/:id/export?format=json|csv`
- POST `/user/playlists/import` (multipart)
### Tags (optional)
- GET `/user/tags`
- POST `/user/tags` → { name }
- DELETE `/user/tags/:id`
- POST `/user/tags/apply` → { provider, videoId, tagIds:[...] }
- GET `/user/tags/videos?tagId=...`
### Sync & Export
- GET `/sync/changes?since=ISO8601` → changeset with tombstones
- POST `/sync/changes` → upserts a client changeset (LWW)
- GET `/user/export?scope=all|profile|history|subscriptions|playlists&format=json`
## Security
- Passwords: Argon2id (preferred) or bcrypt (cost 9, e.g., 12+). Store only the hash.
- Access token: JWT (15 min), in Authorization header. Claims: sub, sid, iat, exp.
- Refresh token: random 256-bit, stored server-side as a hash in `sessions`. Sent as HttpOnly cookie (Secure, SameSite=Strict). Rotate on every `/auth/refresh`.
- CSRF: JWT in header mitigates CSRF; if you expose refresh cookie endpoints, also use Double-Submit Cookie (X-CSRF-Token header).
- XSS: Never put tokens in localStorage. Use Content Security Policy (CSP), escape outputs, sanitize HTML.
- Rate limiting: strict on `/auth/login` by IP and by username (e.g., 5/min), with exponential backoff.
- CORS: whitelist origins; cookies Secure in production; HSTS via reverse proxy.
## Frontend Integration (Angular)
Services and utilities to add in `src/services/`:
- `auth.service.ts`
- `login(username, password, rememberMe)`, `register(...)`, `logout(allDevices?)`, `refresh()`
- Holds in-memory `accessToken` and a `currentUser` signal.
- `user.service.ts`
- `loadMe()`, `loadPreferences()`, `updatePreferences(partial)`
- Emits `preferences` signal.
- `history.service.ts`
- `recordSearch(query, filters?)`
- `recordWatchStart(provider, videoId, title?)`
- `recordWatchProgress(id, progressSeconds)` and `recordWatchEnd(...)`
- `subscriptions.service.ts`, `playlists.service.ts` (CRUD + helpers)
- `auth.interceptor.ts`
- Attaches `Authorization: Bearer <accessToken>` when present.
- Handles 401 by attempting a transparent `refresh()` then retries once.
- `auth.guard.ts`
- Protects `/account/**` routes.
Wiring into current UI:
- Bootstrap (e.g., in `index.tsx` or app initializer):
- If a refresh cookie exists, call `/auth/refresh` to obtain an access token on startup.
- Fetch `/user/preferences` and apply:
- Set default provider, region via `InstanceService` (e.g., `instances.setSelectedProvider(prefs.default_provider)` and `instances.setRegion(...)`).
- Apply theme.
- Search:
- On successful query (`search.component.ts`), call `historyService.recordSearch(q, filters?)`.
- Watch:
- On page load, call `recordWatchStart(...)`.
- Optionally, add event listeners in `VideoPlayerComponent` to periodically call `recordWatchProgress(...)` and on end `recordWatchEnd(...)`.
- Account UI (routes to add):
- `/account` (container), with tabs:
- Profile (username readonly, email, change password)
- Preferences (language, provider, theme, quality, region)
- History (Search/Watch, filters + delete)
- Subscriptions (with categories, notify toggle)
- Playlists (CRUD, reorder, export/import)
- Sessions (active sessions list, revoke)
- Data & Sync (export, import, sync now)
## Dev Proxy (Angular)
Add the following entry to `proxy.conf.json` (do not remove existing ones):
```json
"/proxy/api": {
"target": "http://localhost:4000",
"secure": false,
"changeOrigin": true,
"logLevel": "debug",
"pathRewrite": { "^/proxy/api": "/api" }
}
```
Then you can call API endpoints from the frontend at `/proxy/api/...`.
## Backend Implementation Notes
- Runtime: Node.js.
- Framework: NestJS (recommended) or Express + Zod/class-validator.
- ORM: Prisma (SQLite provider) or Drizzle ORM. Use migrations.
- Security: `helmet`, `express-rate-limit`, `hpp`, `cors` (strict), `argon2`.
- Tokens: `jsonwebtoken` with EdDSA/RS256; `cookie` for setting refresh.
- Logs: `pino` or `winston`.
- Prod: reverse proxy (nginx/Traefik), TLS, backups of SQLite. Use `PRAGMA journal_mode=WAL`.
## Roadmap (Prioritized)
1. High  Backend MVP with `/auth`, `/user/preferences`, `/auth/refresh`, sessions and audit.
2. High  Angular `auth.service`, `auth.interceptor`, bootstrap refresh, load preferences and apply to `InstanceService`.
3. High  History endpoints and `history.service` + instrumentation in `SearchComponent` and `WatchComponent`.
4. Medium  Subscriptions model + UI (basic list + categories, notify toggle).
5. Medium  Playlists CRUD + reorder + export/import.
6. Medium  Sessions page (list + revoke), and Data & Sync page (export + manual sync).
7. Low  Tags and personal stats (time watched per provider, top channels, activity by day).
8. Low  Offline-first (desktop): local queue and `/sync/changes`.
## Example JSON Shapes
```json
// User
{
"id": "c73b...",
"username": "alice",
"email": "alice@example.com"
}
// Preferences
{
"language": "fr",
"defaultProvider": "youtube",
"theme": "dark",
"videoQuality": "auto",
"region": "CA",
"version": 1
}
// Subscription
{
"id": "sub_123",
"provider": "youtube",
"channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw",
"channelName": "Google Developers",
"notify": true,
"subscribedAt": "2025-04-05T10:00:00Z"
}
// Watch history item
{
"id": "wh_123",
"provider": "youtube",
"videoId": "dQw4w9WgXcQ",
"title": "Rick Astley - Never Gonna Give You Up",
"watchedAt": "2025-04-05T10:05:00Z",
"progressSeconds": 215,
"durationSeconds": 212,
"lastPositionSeconds": 0,
"lastWatchedAt": "2025-04-05T10:09:00Z"
}
```
## Minimal UI Mock (routes)
- `/login`, `/register`
- `/account` (tabs: profile, preferences, history, playlists, subscriptions, sessions, data)
- In header: display username + menu; in guest mode: Login/Register buttons
## Notes specific to NewTube
- Integrate preferences with `InstanceService`:
- On bootstrap, set selected provider and region from server preferences.
- Apply theme globally (e.g., HTML data-theme attribute).
- The current components (`Home`, `Search`, `Watch`) stay unchanged; just add history recording calls.
- Keep using `/proxy/*` pattern consistently in dev; call your API with `/proxy/api/...`.
- Start with MVP endpoints (auth + preferences) before playlists/subscriptions to deliver value early.
---
If you need, I can also provide a NestJS skeleton (modules, entities, DTOs, guards) tailored to this schema and ready to run against SQLite.

211
index.css Normal file
View File

@ -0,0 +1,211 @@
/* Minimal stylesheet to accompany Tailwind CDN styles */
:root {
color-scheme: dark;
}
html, body {
height: 100%;
}
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans,
"Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji";
background-color: #0f172a; /* slate-900 */
color: #e2e8f0; /* slate-200 */
}
/* Container helper to match Tailwind's container defaults a bit closer */
.container {
max-width: 1200px;
}
/* --- Theme System ------------------------------------------------------- */
/* Define CSS variables per theme and override a few common Tailwind utils */
/* Defaults (used as fallback) */
:root {
--bg: #0f172a; /* dark slate */
--text: #e2e8f0; /* slate-200 */
--muted: #94a3b8; /* slate-400 */
--panel: #1f2937; /* slate-800 */
--panel-2: #334155; /* slate-700 */
--overlay: rgba(31,41,55,.5);
--accent: #ef4444; /* red-500/600 */
--accent-hover: #dc2626; /* red-600/700 */
--border: #334155;
--selection: rgba(255,255,255,.06); /* default subtle selection for dark */
}
/* Dark */
[data-theme="dark"] {
color-scheme: dark;
/* Dark gray palette (softer than deep slate) */
--bg: #14171b; /* base background */
--text: #e5e7eb; /* slightly brighter text */
--muted: #a1a7b3; /* softer secondary text */
--panel: #1d2126; /* cards/headers */
--panel-2: #262b31; /* inputs/secondary panels */
--overlay: rgba(29,33,38,.6);
--accent: #ef4444; /* unchanged accent */
--accent-hover: #dc2626;
--border: #2f3540; /* subtle border */
--selection: #2b3139; /* hover/selection on dark */
}
/* Pure Black */
[data-theme="black"] {
color-scheme: dark;
--bg: #000000;
--text: #e5e7eb;
--muted: #9ca3af;
--panel: #0a0a0a;
--panel-2: #111111;
--overlay: rgba(0,0,0,.6);
--accent: #ef4444;
--accent-hover: #dc2626;
--border: #1f2937;
--selection: #121416;
}
/* Light */
[data-theme="light"] {
color-scheme: light;
--bg: #f8fafc; /* slate-50 */
--text: #0f172a; /* slate-900 */
--muted: #64748b;/* slate-500 for secondary text */
--panel: #ffffff;
--panel-2: #f1f5f9; /* slate-100 */
--overlay: #ffffff; /* solid white for cards/headers using bg-slate-800/50 */
--accent: #ef4444;
--accent-hover: #dc2626;
--border: #e2e8f0;
--selection: #eef2f7; /* light mode selection (ref. YouTube style) */
}
/* Blue (navy) */
[data-theme="blue"] {
color-scheme: dark;
--bg: #0b1220;
--text: #dbeafe; /* blue-100 */
--muted: #93c5fd; /* blue-300 */
--panel: #111a2b;
--panel-2: #17223a;
--overlay: rgba(17,26,43,.6);
--accent: #3b82f6; /* blue-500 */
--accent-hover: #2563eb; /* blue-600 */
--border: #1e3a8a; /* blue-900 */
--selection: #1a2440;
}
/* System: default to light, override when OS is dark */
[data-theme="system"] {
color-scheme: light;
--bg: #f8fafc;
--text: #0f172a;
--muted: #334155;
--panel: #ffffff;
--panel-2: #f1f5f9;
--overlay: rgba(255,255,255,.8);
--accent: #ef4444;
--accent-hover: #dc2626;
--border: #e2e8f0;
--selection: #eef2f7;
}
@media (prefers-color-scheme: dark) {
[data-theme="system"] {
color-scheme: dark;
--bg: #0f172a;
--text: #e2e8f0;
--muted: #94a3b8;
--panel: #1f2937;
--panel-2: #334155;
--overlay: rgba(31,41,55,.5);
--accent: #ef4444;
--accent-hover: #dc2626;
--border: #334155;
--selection: #2b3139;
}
}
/* Apply variables */
body {
background-color: var(--bg);
color: var(--text);
}
/* Mild overrides so Tailwind utilities adapt to themes without rebuild */
[data-theme] .bg-slate-900 { background-color: var(--bg) !important; }
[data-theme] .bg-gray-800,
[data-theme] .bg-slate-800 { background-color: var(--panel) !important; }
[data-theme] .bg-slate-700 { background-color: var(--panel-2) !important; }
[data-theme] .bg-slate-800\/50 { background-color: var(--overlay) !important; }
[data-theme] .text-slate-200 { color: var(--text) !important; }
[data-theme] .text-slate-100 { color: var(--text) !important; }
[data-theme] .text-slate-300 { color: color-mix(in srgb, var(--text) 80%, transparent) !important; }
[data-theme] .text-slate-400 { color: var(--muted) !important; }
[data-theme] .text-white { color: var(--text) !important; }
[data-theme] .border-slate-700 { border-color: var(--border) !important; }
/* Accent buttons */
[data-theme] .bg-red-600 { background-color: var(--accent) !important; }
[data-theme] .hover\:bg-red-500:hover { background-color: var(--accent-hover) !important; }
[data-theme] .bg-gray-600 { background-color: var(--panel-2) !important; }
[data-theme] .hover\:bg-gray-500:hover { background-color: var(--panel) !important; }
/* Light-specific refinements */
[data-theme="light"] header {
box-shadow: 0 2px 12px rgba(0,0,0,.06);
}
[data-theme="light"] .shadow-2xl {
box-shadow: 0 25px 50px -12px rgba(0,0,0,.15) !important;
}
[data-theme="light"] .bg-black\/75 {
/* make duration pill less heavy on light */
background-color: rgba(0,0,0,.5) !important;
}
/* Cards and panels separation in Light */
[data-theme="light"] .bg-slate-800,
[data-theme="light"] .bg-gray-800,
[data-theme="light"] .bg-slate-800\/50 {
border: 1px solid var(--border) !important;
box-shadow: 0 1px 2px rgba(0,0,0,.05);
}
/* Hover states for list/grid cards */
[data-theme="light"] .hover\:bg-slate-700\/50:hover,
[data-theme="light"] .hover\:bg-slate-800:hover,
[data-theme="light"] .hover\:bg-slate-800\/50:hover,
[data-theme="light"] .hover\:bg-slate-700:hover,
[data-theme="light"] .hover\:bg-gray-800:hover {
background-color: var(--selection) !important; /* subtle hover */
}
/* In light mode, also treat any active dark-slate background as selection */
[data-theme="light"] .bg-slate-800,
[data-theme="light"] .bg-slate-800\/50,
[data-theme="light"] .bg-gray-800 {
background-color: var(--panel) !important;
border: 1px solid var(--border) !important;
}
/* Inputs in light mode */
[data-theme="light"] input.bg-slate-700,
[data-theme="light"] input.bg-gray-800,
[data-theme="light"] select.bg-gray-800,
[data-theme="light"] select.bg-slate-800,
[data-theme="light"] textarea.bg-gray-800,
[data-theme="light"] textarea.bg-slate-800 {
background-color: var(--panel-2) !important;
color: var(--text) !important;
border: 1px solid var(--border) !important;
}
[data-theme="light"] input::placeholder,
[data-theme="light"] textarea::placeholder {
color: #94a3b8; /* slate-400 */
}
/* Hide scrollbars on horizontal lists where we still want scrolling */
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }

57
index.html Normal file
View File

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>NewTube</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<script>
try {
var t = localStorage.getItem('newtube.theme') || 'system';
document.documentElement.setAttribute('data-theme', t);
} catch (e) {}
</script>
<script src="https://cdn.tailwindcss.com?plugins=line-clamp"></script>
<style>
/* Custom scrollbar for a better look */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #1e1e2e;
}
::-webkit-scrollbar-thumb {
background: #45475a;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #6c7086;
}
</style>
<script type="importmap">
{
"imports": {
"rxjs": "https://aistudiocdn.com/rxjs@^7.8.2?conditions=es2015",
"rxjs/operators": "https://aistudiocdn.com/rxjs@^7.8.2/operators?conditions=es2015",
"rxjs/ajax": "https://aistudiocdn.com/rxjs@^7.8.2/ajax?conditions=es2015",
"rxjs/webSocket": "https://aistudiocdn.com/rxjs@^7.8.2/webSocket?conditions=es2015",
"rxjs/testing": "https://aistudiocdn.com/rxjs@^7.8.2/testing?conditions=es2015",
"rxjs/fetch": "https://aistudiocdn.com/rxjs@^7.8.2/fetch?conditions=es2015",
"@angular/compiler": "https://next.esm.sh/@angular/compiler@^20.1.6-0?external=rxjs",
"@angular/platform-browser": "https://next.esm.sh/@angular/platform-browser@^20.1.6-0?external=rxjs",
"@angular/router": "https://next.esm.sh/@angular/router@^20.1.6-0?external=rxjs",
"@angular/common/http": "https://next.esm.sh/@angular/common@^20.1.6-0/http?external=rxjs",
"@angular/core": "https://next.esm.sh/@angular/core@^20.1.6-0?external=rxjs",
"@angular/common": "https://next.esm.sh/@angular/common@^20.1.6-0?external=rxjs",
"@google/genai": "https://esm.run/@google/genai"
}
}
</script>
<link rel="stylesheet" href="/index.css">
<!-- Local, non-versioned config (define YOUTUBE_API_KEY, GEMINI_API_KEY, etc.) -->
<script src="assets/config.local.js"></script>
</head>
<body class="bg-slate-900 text-slate-200 antialiased">
<app-root></app-root>
</body>
</html>

54
index.tsx Normal file
View File

@ -0,0 +1,54 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, withHashLocation } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideZonelessChangeDetection, APP_INITIALIZER } from '@angular/core';
import { AppComponent } from './src/app.component';
import { APP_ROUTES } from './src/app.routes';
import { authInterceptor } from './src/interceptors/auth.interceptor';
import { AuthService } from './src/services/auth.service';
import { UserService } from './src/services/user.service';
import type { UserPreferences } from './src/services/user.service';
import { InstanceService } from './src/services/instance.service';
import { firstValueFrom } from 'rxjs';
function applyTheme(theme?: string | null) {
const t = theme || 'system';
try {
document.documentElement.setAttribute('data-theme', t);
} catch {}
}
function appInitializerFactory(auth: AuthService, user: UserService, instances: InstanceService) {
return async () => {
try {
// Attempt refresh on startup (if cookies present)
const ok = await firstValueFrom(auth.refresh());
if (ok) {
// Load user profile for header greeting
try {
const me = await firstValueFrom(user.loadMe());
if (me) auth.setCurrentUser(me);
} catch {}
const prefs: UserPreferences = await firstValueFrom(user.loadPreferences());
if (prefs) {
if (prefs.defaultProvider) instances.setSelectedProvider(prefs.defaultProvider as any);
if (prefs.region) instances.setRegion(prefs.region);
applyTheme(prefs.theme);
}
}
} catch {}
};
}
bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(),
provideRouter(APP_ROUTES, withHashLocation()),
provideHttpClient(withInterceptors([authInterceptor])),
{ provide: APP_INITIALIZER, useFactory: appInitializerFactory, deps: [AuthService, UserService, InstanceService], multi: true },
],
}).catch((err) => console.error(err));
// AI Studio always uses an `index.tsx` file for all project types.

5
metadata.json Normal file
View File

@ -0,0 +1,5 @@
{
"name": "NewTube",
"description": "A privacy-respecting frontend for a popular video platform. Browse and watch videos without tracking, using alternative APIs like Piped or Invidious.",
"requestFramePermissions": []
}

9807
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "newtube",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "ng serve",
"build": "ng build",
"preview": "ng serve --configuration=production",
"api": "node --env-file=.env.local ./server/index.mjs",
"api:watch": "node --watch --env-file=.env.local ./server/index.mjs"
},
"dependencies": {
"@angular/build": "^20.1.0",
"@angular/cli": "^20.1.0",
"@angular/common": "^20.1.0",
"@angular/compiler": "^20.1.0",
"@angular/compiler-cli": "^20.1.0",
"@angular/core": "^20.1.0",
"@angular/forms": "^20.1.0",
"@angular/platform-browser": "^20.1.0",
"@angular/router": "^20.1.6-0",
"@google/genai": "latest",
"axios": "^1.12.1",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^9.6.0",
"cheerio": "^1.1.2",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"express": "^4.19.2",
"express-rate-limit": "^7.1.5",
"ffmpeg-static": "^5.2.0",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"rxjs": "^7.8.2",
"tailwindcss": "latest",
"youtube-dl-exec": "^3.0.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

74
proxy.conf.json Normal file
View File

@ -0,0 +1,74 @@
{
"/proxy/yt": {
"target": "https://www.googleapis.com",
"secure": true,
"changeOrigin": true,
"logLevel": "debug",
"pathRewrite": {
"^/proxy/yt": ""
}
},
"/proxy/dm": {
"target": "https://api.dailymotion.com",
"secure": true,
"changeOrigin": true,
"logLevel": "debug",
"pathRewrite": {
"^/proxy/dm": ""
}
},
"/proxy/twitch-api": {
"target": "https://api.twitch.tv",
"secure": true,
"changeOrigin": true,
"logLevel": "debug",
"pathRewrite": {
"^/proxy/twitch-api": ""
}
},
"/proxy/twitch-auth": {
"target": "https://id.twitch.tv",
"secure": true,
"changeOrigin": true,
"logLevel": "debug",
"pathRewrite": {
"^/proxy/twitch-auth": ""
}
},
"/proxy/odysee": {
"target": "https://api.na-backend.odysee.com",
"secure": true,
"changeOrigin": true,
"logLevel": "debug",
"pathRewrite": {
"^/proxy/odysee": ""
}
},
"/proxy/rumble": {
"target": "https://rumble.com/api/v0",
"secure": true,
"changeOrigin": true,
"logLevel": "debug",
"pathRewrite": {
"^/proxy/rumble": ""
}
},
"/api/rumble": {
"target": "http://localhost:4000",
"secure": false,
"changeOrigin": true,
"logLevel": "debug",
"pathRewrite": {
"^/api/rumble": "/api/rumble"
}
},
"/proxy/api": {
"target": "http://localhost:4000",
"secure": false,
"changeOrigin": true,
"logLevel": "debug",
"pathRewrite": {
"^/proxy/api": "/api"
}
}
}

210
server/db.mjs Normal file
View File

@ -0,0 +1,210 @@
import fs from 'node:fs';
import path from 'node:path';
import { randomBytes, randomUUID } from 'node:crypto';
import Database from 'better-sqlite3';
const root = process.cwd();
const dbDir = path.join(root, 'db');
const dbFile = path.join(dbDir, 'newtube.db');
const schemaFile = path.join(dbDir, 'schema.sql');
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
// Create DB and enable FKs
const db = new Database(dbFile);
db.pragma('foreign_keys = ON');
// Run schema if present
if (fs.existsSync(schemaFile)) {
const ddl = fs.readFileSync(schemaFile, 'utf8');
if (ddl && ddl.trim().length) {
db.exec(ddl);
}
}
// Helpers
export function nowIso() {
return new Date().toISOString();
}
export function getUserByUsername(username) {
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
}
export function getUserById(id) {
return db.prepare('SELECT * FROM users WHERE id = ?').get(id);
}
export function insertUser({ id, username, email, passwordHash }) {
const ts = nowIso();
db.prepare(`INSERT INTO users (id, username, email, password_hash, is_active, created_at, updated_at)
VALUES (@id, @username, @email, @passwordHash, 1, @ts, @ts)`).run({ id, username, email, passwordHash, ts });
// Default preferences row
db.prepare(`INSERT INTO user_preferences (user_id, language, default_provider, theme, video_quality, region, version, updated_at)
VALUES (@id, 'en', 'youtube', 'system', 'auto', 'US', 1, @ts)`).run({ id, ts });
}
export function upsertPreferences(userId, patch) {
const current = db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(userId);
if (!current) {
const merged = {
language: patch.language ?? 'en',
default_provider: patch.defaultProvider ?? 'youtube',
theme: patch.theme ?? 'system',
video_quality: patch.videoQuality ?? 'auto',
region: patch.region ?? 'US',
version: 1,
};
db.prepare(`INSERT INTO user_preferences (user_id, language, default_provider, theme, video_quality, region, version, updated_at)
VALUES (@userId, @language, @default_provider, @theme, @video_quality, @region, @version, @updated_at)`)
.run({ userId, ...merged, updated_at: nowIso() });
} else {
const merged = {
language: patch.language ?? current.language,
default_provider: patch.defaultProvider ?? current.default_provider,
theme: patch.theme ?? current.theme,
video_quality: patch.videoQuality ?? current.video_quality,
region: patch.region ?? current.region,
version: (current.version ?? 1) + 1,
updated_at: nowIso(),
};
db.prepare(`UPDATE user_preferences
SET language=@language, default_provider=@default_provider, theme=@theme,
video_quality=@video_quality, region=@region, version=@version, updated_at=@updated_at
WHERE user_id=@userId`)
.run({ userId, ...merged });
}
}
export function getPreferences(userId) {
return db.prepare('SELECT language, default_provider AS defaultProvider, theme, video_quality AS videoQuality, region, version, updated_at FROM user_preferences WHERE user_id = ?').get(userId);
}
export function insertSession({ id, userId, refreshTokenHash, isRemember, userAgent, deviceInfo, ip, expiresAt }) {
const ts = nowIso();
db.prepare(`INSERT INTO sessions (id, user_id, refresh_token_hash, user_agent, device_info, ip_address, is_remember, created_at, last_seen_at, expires_at)
VALUES (@id, @userId, @refreshTokenHash, @userAgent, @deviceInfo, @ip, @isRemember, @ts, @ts, @expiresAt)`)
.run({ id, userId, refreshTokenHash, userAgent, deviceInfo, ip, isRemember: isRemember ? 1 : 0, ts, expiresAt });
}
export function getSessionById(id) {
return db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
}
export function updateSessionToken(id, refreshTokenHash, expiresAt) {
const ts = nowIso();
db.prepare('UPDATE sessions SET refresh_token_hash = ?, last_seen_at = ?, expires_at = ?, revoked_at = NULL WHERE id = ?')
.run(refreshTokenHash, ts, expiresAt, id);
}
export function revokeSession(id) {
const ts = nowIso();
db.prepare('UPDATE sessions SET revoked_at = ? WHERE id = ?').run(ts, id);
}
export function revokeAllUserSessions(userId) {
const ts = nowIso();
db.prepare('UPDATE sessions SET revoked_at = ? WHERE user_id = ?').run(ts, userId);
}
export function listUserSessions(userId) {
return db.prepare('SELECT id, user_agent AS userAgent, device_info AS deviceInfo, ip_address AS ip, is_remember AS isRemember, created_at AS createdAt, last_seen_at AS lastSeenAt, expires_at AS expiresAt, revoked_at AS revokedAt FROM sessions WHERE user_id = ? ORDER BY created_at DESC').all(userId);
}
export function setUserLastLogin(userId) {
const ts = nowIso();
db.prepare('UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?').run(ts, ts, userId);
}
export function insertLoginAudit({ userId, username, ip, userAgent, success, reason }) {
const id = cryptoRandomId();
const ts = nowIso();
db.prepare(`INSERT INTO login_audit (id, user_id, username, ip_address, user_agent, success, reason, created_at)
VALUES (@id, @userId, @username, @ip, @userAgent, @success, @reason, @ts)`)
.run({ id, userId, username, ip, userAgent, success: success ? 1 : 0, reason: reason || null, ts });
}
export function cryptoRandomId() {
// simple URL-safe base64 16 bytes id
return randomBytes(16).toString('base64url');
}
export function cryptoRandomUUID() {
return randomUUID();
}
export default db;
// -------------------- Search History --------------------
export function insertSearchHistory({ userId, query, filters }) {
const id = cryptoRandomId();
const created_at = nowIso();
const filters_json = filters ? JSON.stringify(filters) : null;
db.prepare(`INSERT INTO search_history (id, user_id, query, filters_json, created_at)
VALUES (?, ?, ?, ?, ?)`)
.run(id, userId, query, filters_json, created_at);
return { id, created_at };
}
export function listSearchHistory({ userId, limit = 50, before }) {
const rows = before
? db.prepare(`SELECT * FROM search_history WHERE user_id = ? AND created_at < ? ORDER BY created_at DESC LIMIT ?`).all(userId, before, limit)
: db.prepare(`SELECT * FROM search_history WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`).all(userId, limit);
return rows;
}
export function deleteSearchHistoryById(userId, id) {
db.prepare(`DELETE FROM search_history WHERE id = ? AND user_id = ?`).run(id, userId);
}
export function deleteAllSearchHistory(userId) {
db.prepare(`DELETE FROM search_history WHERE user_id = ?`).run(userId);
}
// -------------------- Watch History --------------------
export function upsertWatchHistory({ userId, provider, videoId, title, watchedAt, progressSeconds = 0, durationSeconds = 0, lastPositionSeconds = 0 }) {
const now = nowIso();
const watched_at = watchedAt || now;
// Insert or update on unique (user_id, provider, video_id)
db.prepare(`INSERT INTO watch_history (id, user_id, provider, video_id, title, watched_at, progress_seconds, duration_seconds, last_position_seconds, last_watched_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, provider, video_id) DO UPDATE SET
title=COALESCE(excluded.title, title),
progress_seconds=MAX(excluded.progress_seconds, watch_history.progress_seconds),
duration_seconds=MAX(excluded.duration_seconds, watch_history.duration_seconds),
last_position_seconds=excluded.last_position_seconds,
last_watched_at=excluded.last_watched_at`).run(
cryptoRandomId(), userId, provider, videoId, title || null, watched_at, progressSeconds, durationSeconds, lastPositionSeconds, now
);
// Return the row id
const row = db.prepare(`SELECT * FROM watch_history WHERE user_id = ? AND provider = ? AND video_id = ?`).get(userId, provider, videoId);
return row;
}
export function updateWatchHistoryById(id, { progressSeconds, lastPositionSeconds }) {
const now = nowIso();
const row = db.prepare(`SELECT * FROM watch_history WHERE id = ?`).get(id);
if (!row) return null;
const nextProgress = (typeof progressSeconds === 'number') ? Math.max(progressSeconds, row.progress_seconds || 0) : row.progress_seconds;
const nextLastPos = (typeof lastPositionSeconds === 'number') ? lastPositionSeconds : row.last_position_seconds;
db.prepare(`UPDATE watch_history SET progress_seconds = ?, last_position_seconds = ?, last_watched_at = ? WHERE id = ?`)
.run(nextProgress, nextLastPos, now, id);
return db.prepare(`SELECT * FROM watch_history WHERE id = ?`).get(id);
}
export function listWatchHistory({ userId, limit = 50, before }) {
const rows = before
? db.prepare(`SELECT * FROM watch_history WHERE user_id = ? AND watched_at < ? ORDER BY watched_at DESC LIMIT ?`).all(userId, before, limit)
: db.prepare(`SELECT * FROM watch_history WHERE user_id = ? ORDER BY watched_at DESC LIMIT ?`).all(userId, limit);
return rows;
}
export function deleteWatchHistoryById(userId, id) {
db.prepare(`DELETE FROM watch_history WHERE id = ? AND user_id = ?`).run(id, userId);
}
export function deleteAllWatchHistory(userId) {
db.prepare(`DELETE FROM watch_history WHERE user_id = ?`).run(userId);
}

1067
server/index.mjs Normal file

File diff suppressed because it is too large Load Diff

164
server/rumble.mjs Normal file
View File

@ -0,0 +1,164 @@
import express from 'express';
import * as cheerio from 'cheerio';
import axios from 'axios';
import rateLimit from 'express-rate-limit';
const router = express.Router();
// Rate limiter for Rumble scraping to prevent being blocked
const rumbleLimiter = rateLimit({
windowMs: 60 * 1000, // 1 min
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests to Rumble API. Please try again later.' }
});
router.use(rumbleLimiter);
// Simple in-memory cache with TTL
const cache = new Map();
const TTL_MS = 60 * 1000; // 60s
function cacheKey(path, params) {
return `${path}?${new URLSearchParams(params).toString()}`;
}
function setCache(key, data) {
cache.set(key, { data, expires: Date.now() + TTL_MS });
}
function getCache(key) {
const hit = cache.get(key);
if (!hit) return null;
if (Date.now() > hit.expires) { cache.delete(key); return null; }
return hit.data;
}
async function httpGet(url) {
const resp = await axios.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.8'
},
timeout: 15000
});
return resp.data;
}
async function scrapeRumbleVideo(videoId) {
try {
const html = await httpGet(`https://rumble.com/${videoId}`);
const $ = cheerio.load(html);
const title = $('h1.video-title, .video-title h1').first().text().trim() || $('meta[property="og:title"]').attr('content') || '';
const thumbnail = $('meta[property="og:image"]').attr('content') || '';
const uploaderName = $('.media-by--a, .channel-name').first().text().trim() || '';
const viewsText = $('.rumbles-views, .video-views').first().text().trim();
const views = parseInt((viewsText || '').replace(/[^0-9]/g, '')) || 0;
const durationText = $('meta[property="video:duration"]').attr('content');
const duration = durationText ? parseInt(durationText) : 0;
const uploadedDate = $('meta[property="article:published_time"]').attr('content') || '';
const description = $('meta[property="og:description"]').attr('content') || '';
return { videoId, title: title || 'Untitled Video', thumbnail, uploaderName: uploaderName || 'Unknown Uploader', views, duration, uploadedDate, description, url: `https://rumble.com/${videoId}`, type: 'video' };
} catch (e) {
console.error('scrapeRumbleVideo error:', e.message);
return { videoId, error: 'Scraping failed' };
}
}
async function scrapeRumbleList({ q, page = 1, limit = 24, sort = 'viral' }) {
try {
const url = q
? `https://rumble.com/search/video?q=${encodeURIComponent(q)}&page=${page}`
: `https://rumble.com/videos?sort=${encodeURIComponent(sort)}&page=${page}`;
const html = await httpGet(url);
const $ = cheerio.load(html);
const items = [];
// Try to select video cards; Rumble uses different layouts, so search broadly
$('a[href^="/v"], a[href^="/video/"]').each((_, el) => {
const a = $(el);
const href = a.attr('href') || '';
// Expect href like /vabcdef or /video/abcdef
const m = href.match(/\/v([A-Za-z0-9]+)/) || href.match(/\/video\/([A-Za-z0-9]+)/);
if (!m) return;
const vid = `v${m[1]}`;
const title = a.attr('title') || a.text().trim();
// Look around for thumbnail and meta
const parent = a.closest('li, article, div');
const img = parent.find('img').first();
let thumb = img.attr('data-src') || img.attr('src') || '';
if (thumb && thumb.startsWith('//')) thumb = 'https:' + thumb;
const durationText = parent.find('.video-item--duration, .video-duration, .duration').first().text().trim();
const viewsText = parent.find('.video-item--views, .rumbles-views, .views').first().text().trim();
const duration = (() => {
const m = durationText.match(/(\d+):(\d+)(?::(\d+))?/);
if (!m) return 0;
const h = parseInt(m[3] || '0', 10), mn = parseInt(m[1] || '0', 10), s = parseInt(m[2] || '0', 10);
return h * 3600 + mn * 60 + s;
})();
const views = parseInt((viewsText || '').replace(/[^0-9]/g, '')) || 0;
items.push({ videoId: vid, title, thumbnail: thumb, uploaderName: '', views, duration, uploadedDate: '', url: `https://rumble.com/${vid}`, type: 'video' });
});
// De-duplicate by videoId and slice to limit
const seen = new Set();
const unique = [];
for (const it of items) { if (!seen.has(it.videoId)) { seen.add(it.videoId); unique.push(it); } }
const list = unique.slice(0, limit);
const nextCursor = list.length === limit ? String(Number(page) + 1) : null;
return { items: list, total: unique.length, page: Number(page), limit: Number(limit), nextCursor };
} catch (e) {
console.error('scrapeRumbleList error:', e.message);
return { items: [], total: 0, page: Number(page), limit: Number(limit), nextCursor: null };
}
}
router.get('/browse', async (req, res) => {
const page = parseInt(String(req.query.page || '1'), 10) || 1;
const limit = Math.min(50, parseInt(String(req.query.limit || '24'), 10) || 24);
const sort = String(req.query.sort || 'viral');
const key = cacheKey('/browse', { page, limit, sort });
const cached = getCache(key);
if (cached) return res.json(cached);
const data = await scrapeRumbleList({ page, limit, sort });
setCache(key, data);
return res.json(data);
});
router.get('/search', async (req, res) => {
const q = String(req.query.q || '').trim();
if (!q) return res.status(400).json({ error: 'Query parameter required' });
const limit = Math.min(50, parseInt(String(req.query.limit || '24'), 10) || 24);
const page = (() => {
// Support offset-based cursor from frontend by translating offset->page
if (req.query.offset != null) {
const offset = parseInt(String(req.query.offset), 10) || 0;
return Math.floor(offset / limit) + 1;
}
return parseInt(String(req.query.page || '1'), 10) || 1;
})();
const key = cacheKey('/search', { q, page, limit });
const cached = getCache(key);
if (cached) return res.json(cached);
const data = await scrapeRumbleList({ q, page, limit });
setCache(key, data);
return res.json(data);
});
router.get('/video/:videoId', async (req, res) => {
try {
const { videoId } = req.params;
const key = cacheKey('/video', { videoId });
const cached = getCache(key);
if (cached) return res.json(cached);
const videoData = await scrapeRumbleVideo(videoId);
if (videoData.error) return res.status(404).json({ error: 'Video not found or scraping failed' });
setCache(key, videoData);
return res.json(videoData);
} catch (error) {
console.error('Rumble video error:', error);
return res.status(500).json({ error: 'Failed to scrape video' });
}
});
export default router;

29
src/app.component.html Normal file
View File

@ -0,0 +1,29 @@
<div class="min-h-screen flex flex-col">
<app-header (menuToggle)="toggleSidebar()"></app-header>
<div class="pt-16">
<!-- Global Themes bar: shown on Home and Theme pages, sticky under header -->
@if (showThemesBar()) {
<app-themes-nav></app-themes-nav>
}
<div class="flex flex-1 relative">
<!-- Desktop sidebar -->
<aside class="hidden md:block border-r border-slate-800 sticky top-16 self-start h-[calc(100vh-4rem)] transition-[width] duration-200"
[class.w-64]="!sidebarCollapsed()" [class.w-20]="sidebarCollapsed()">
<app-sidebar [collapsed]="sidebarCollapsed()"></app-sidebar>
</aside>
<!-- Mobile drawer sidebar -->
@if (sidebarOpen()) {
<div class="fixed inset-0 bg-black/60 z-40 md:hidden" (click)="closeSidebar()"></div>
}
<div class="fixed inset-y-0 left-0 w-72 z-40 bg-slate-900 transform transition-transform duration-200 md:hidden"
[class.-translate-x-full]="!sidebarOpen()" [class.translate-x-0]="sidebarOpen()">
<app-sidebar></app-sidebar>
</div>
<main class="flex-1 min-w-0 overflow-x-hidden">
<router-outlet></router-outlet>
</main>
</div>
</div>
</div>

64
src/app.component.ts Normal file
View File

@ -0,0 +1,64 @@
import { ChangeDetectionStrategy, Component, signal, inject } from '@angular/core';
import { Router, RouterOutlet, NavigationEnd } from '@angular/router';
import { HeaderComponent } from './components/header/header.component';
import { SidebarComponent } from './components/sidebar/sidebar.component';
import { ThemesNavComponent } from './components/themes/themes-nav.component';
import { I18nService } from './services/i18n.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterOutlet, HeaderComponent, SidebarComponent, ThemesNavComponent]
})
export class AppComponent {
private i18n = inject(I18nService);
private router = inject(Router);
sidebarOpen = signal(false);
sidebarCollapsed = signal(false);
private _showThemesBar = signal(false);
toggleSidebar() {
// On mobile (< md), open/close drawer. On desktop, collapse/expand sidebar width.
try {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; // Tailwind md breakpoint
if (isMobile) {
this.sidebarOpen.update(v => !v);
} else {
this.sidebarCollapsed.update(v => !v);
}
} catch {
this.sidebarOpen.update(v => !v);
}
}
closeSidebar() {
this.sidebarOpen.set(false);
}
constructor() {
// Initialize and react to route changes to show/hide the global themes bar
try {
this.updateShowThemesBar();
this.router.events.subscribe(e => {
if (e instanceof NavigationEnd) this.updateShowThemesBar();
});
} catch {}
}
private updateShowThemesBar() {
try {
const url = this.router.url || '/';
const isHome = url === '/' || url.startsWith('/?');
const isTheme = url.startsWith('/t/') || /^\/p\/[^/]+\/t\//.test(url);
this._showThemesBar.set(isHome || isTheme);
} catch {
this._showThemesBar.set(false);
}
}
// Template helpers
showThemesBar() { return this._showThemesBar(); }
}

94
src/app.routes.ts Normal file
View File

@ -0,0 +1,94 @@
import { Routes } from '@angular/router';
export const APP_ROUTES: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 't/trending' },
// Theme pages
{
path: 't/:theme',
loadComponent: () => import('./components/themes/theme-page.component').then(m => m.ThemePageComponent),
title: 'NewTube - Theme'
},
{
path: 'p/:provider/t/:theme',
loadComponent: () => import('./components/themes/provider-theme-page.component').then(m => m.ProviderThemePageComponent),
title: 'NewTube - Provider Theme'
},
{
path: 'search',
loadComponent: () => import('./components/search/search.component').then(m => m.SearchComponent),
title: 'NewTube - Search'
},
{
path: 'shorts',
loadComponent: () => import('./components/shorts/watch-short.component').then(m => m.WatchShortComponent),
title: 'NewTube - Shorts'
},
{
path: 'watch/:id',
loadComponent: () => import('./components/watch/watch.component').then(m => m.WatchComponent),
title: 'NewTube - Watch'
},
// Alias supporting provider in path: /watch/:provider/:id
{
path: 'watch/:provider/:id',
loadComponent: () => import('./components/watch/watch.component').then(m => m.WatchComponent),
title: 'NewTube - Watch'
},
{
path: 'library',
pathMatch: 'full',
redirectTo: 'library/playlists'
},
{
path: 'library/playlists',
loadComponent: () => import('./components/library/playlists/playlists.component').then(m => m.PlaylistsComponent),
title: 'NewTube - Playlists'
},
{
path: 'library/liked',
loadComponent: () => import('./components/library/liked/liked.component').then(m => m.LikedComponent),
title: 'NewTube - Liked'
},
{
path: 'library/subscriptions',
loadComponent: () => import('./components/library/subscriptions/subscriptions.component').then(m => m.SubscriptionsComponent),
title: 'NewTube - Subscriptions'
},
{
path: 'account',
pathMatch: 'full',
redirectTo: 'account/preferences'
},
{
path: 'account/preferences',
loadComponent: () => import('./components/account/preferences/preferences.component').then(m => m.PreferencesComponent),
title: 'NewTube - Preferences'
},
{
path: 'account/history',
loadComponent: () => import('./components/account/history/history.component').then(m => m.HistoryComponent),
title: 'NewTube - History'
},
{
path: 'account/sessions',
loadComponent: () => import('./components/account/sessions/sessions.component').then(m => m.SessionsComponent),
title: 'NewTube - Sessions'
},
{
path: 'auth/login',
loadComponent: () => import('./components/auth/login/login.component').then(m => m.LoginComponent),
title: 'NewTube - Login'
},
{
path: 'auth/register',
loadComponent: () => import('./components/auth/register/register.component').then(m => m.RegisterComponent),
title: 'NewTube - Register'
},
{
path: 'info/utilisation',
loadComponent: () => import('./components/info/utilisation/utilisation.component').then(m => m.UtilisationComponent),
title: 'NewTube - Utilisation'
},
{ path: '**', redirectTo: '' }
];

View File

@ -0,0 +1,124 @@
<div class="container mx-auto p-4 sm:p-6 max-w-6xl">
<h2 class="text-3xl font-bold mb-8 text-slate-100 border-l-4 border-red-500 pl-4 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Historique
</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Search History Section -->
<section class="bg-slate-800/50 rounded-lg p-6 shadow-lg">
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-semibold text-slate-200 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Historique des recherches
</h3>
<span class="text-xs bg-slate-700 text-slate-300 px-2 py-1 rounded-full">
{{ searchHistory().length }} recherche(s)
</span>
</div>
<div *ngIf="searchHistory().length === 0" class="text-slate-400 text-center py-4">
Aucune recherche récente
</div>
<ul class="space-y-3">
<li *ngFor="let s of searchHistory()"
class="group bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
<a [routerLink]="['/search']" [queryParams]="{ q: s.query, provider: getSearchProvider(s).id }"
class="block p-4 hover:no-underline">
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="text-slate-100 font-medium group-hover:text-blue-400 transition-colors">
{{ s.query }}
</div>
<div class="flex items-center mt-1 text-xs text-slate-400">
<span class="inline-flex items-center bg-slate-800/80 px-2 py-0.5 rounded mr-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
{{ getSearchProvider(s).name }}
</span>
<span class="text-slate-500">
{{ formatDate(s.created_at) }}
</span>
</div>
</div>
<button class="text-slate-400 hover:text-blue-400 p-1 -mr-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</a>
</li>
</ul>
</section>
<!-- Watch History Section -->
<section class="bg-slate-800/50 rounded-lg p-6 shadow-lg">
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-semibold text-slate-200 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Historique de visionnage
</h3>
<span class="text-xs bg-slate-700 text-slate-300 px-2 py-1 rounded-full">
{{ watchHistory().length }} vidéo(s)
</span>
</div>
<div *ngIf="watchHistory().length === 0" class="text-slate-400 text-center py-4">
Aucune vidéo récemment regardée
</div>
<ul class="space-y-3">
<li *ngFor="let w of watchHistory()"
class="group bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
<a [routerLink]="['/watch', w.video_id]" [queryParams]="{ p: w.provider }"
class="block p-4 hover:no-underline">
<div class="flex">
<div class="relative flex-shrink-0 w-24 h-16 bg-slate-700 rounded overflow-hidden flex items-center justify-center">
<div class="text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</div>
<div class="absolute bottom-0 left-0 right-0 h-1 bg-slate-600">
<div class="h-full bg-red-500" [style.width.%]="getProgressPercentage(w)"></div>
</div>
<div *ngIf="w.duration_seconds" class="absolute bottom-1 right-1 bg-black/80 text-white text-2xs px-1 rounded">
{{ formatDuration(w.duration_seconds) }}
</div>
</div>
<div class="ml-4 flex-1 min-w-0">
<h4 class="text-sm font-medium text-slate-100 group-hover:text-blue-400 transition-colors truncate">
{{ w.title || 'Sans titre' }}
</h4>
<div class="mt-1 text-xs text-slate-400">
<span class="inline-flex items-center bg-slate-800/80 px-2 py-0.5 rounded mr-2">
{{ w.provider }}
</span>
</div>
<div class="mt-2 text-2xs text-slate-500">
<span>Regardé le {{ formatDate(w.watched_at) }}</span>
<span class="mx-2"></span>
<span>Progression: {{ formatDuration(w.progress_seconds || 0) }} / {{ formatDuration(w.duration_seconds || 0) }}</span>
</div>
</div>
<button class="text-slate-400 hover:text-blue-400 p-1 -mr-1 self-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</a>
</li>
</ul>
</section>
</div>
</div>

View File

@ -0,0 +1,139 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common';
import { RouterModule } from '@angular/router';
import { HistoryService, SearchHistoryItem, WatchHistoryItem } from '../../../services/history.service';
@Component({
selector: 'app-history',
standalone: true,
templateUrl: './history.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterModule]
})
export class HistoryComponent {
private history = inject(HistoryService);
loading = signal<boolean>(false);
searchHistory = signal<SearchHistoryItem[]>([]);
watchHistory = signal<WatchHistoryItem[]>([]);
constructor() {
this.reload();
}
reload() {
this.loading.set(true);
this.history.getSearchHistory(50).subscribe({
next: (items) => this.searchHistory.set(items || []),
error: () => {},
});
this.history.getWatchHistory(50).subscribe({
next: (items) => this.watchHistory.set(items || []),
error: () => {},
});
this.loading.set(false);
}
// Format a date to a readable string
formatDate(dateString: string | Date): string {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// Get provider from search filters or query params
getSearchProvider(item: SearchHistoryItem): { name: string; id: string } {
// Try to get provider from filters first
if (item.filters_json) {
try {
const filters = JSON.parse(item.filters_json);
if (filters.provider) {
const providerName = this.formatProviderName(filters.provider);
return { name: providerName, id: filters.provider };
}
} catch (e) {
console.error('Error parsing filters_json:', e);
}
}
// If no provider in filters, try to extract from URL query params
try {
const url = new URL(item.query);
const provider = url.searchParams.get('provider');
if (provider) {
const providerName = this.formatProviderName(provider);
return { name: providerName, id: provider };
}
} catch (e) {
// Not a valid URL or no provider in query params
}
// Default to the selected provider if available
const defaultProvider = this.getDefaultProvider();
if (defaultProvider) {
return { name: this.formatProviderName(defaultProvider), id: defaultProvider };
}
// Fallback to 'all' if no provider can be determined
return { name: 'Tous', id: 'all' };
}
// Get the default provider from the current URL or settings
private getDefaultProvider(): string | null {
try {
// Try to get from current URL
const url = new URL(window.location.href);
const provider = url.searchParams.get('provider');
if (provider) return provider;
// Or get from localStorage if available
const savedProvider = localStorage.getItem('selectedProvider');
if (savedProvider) return savedProvider;
// Default to youtube if nothing else
return 'youtube';
} catch (e) {
return 'youtube'; // Fallback
}
}
// Format provider ID to a display name
private formatProviderName(providerId: string): string {
const providerMap: { [key: string]: string } = {
'youtube': 'YouTube',
'vimeo': 'Vimeo',
'dailymotion': 'Dailymotion',
'peertube': 'PeerTube',
'rumble': 'Rumble'
};
return providerMap[providerId.toLowerCase()] || providerId;
}
// Format seconds to HH:MM:SS or MM:SS
formatDuration(seconds: number): string {
if (!seconds && seconds !== 0) return '00:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
} else {
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
}
// Calculate watch progress percentage
getProgressPercentage(item: WatchHistoryItem): number {
if (!item.duration_seconds || item.duration_seconds === 0) return 0;
const progress = (item.progress_seconds || 0) / item.duration_seconds;
return Math.min(100, Math.max(0, progress * 100));
}
}

View File

@ -0,0 +1,95 @@
<div class="container mx-auto p-4 sm:p-6">
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">{{ 'preferences.title' | t }}</h2>
<!-- Notices -->
<div *ngIf="notice()" class="mb-4 bg-emerald-900/30 border border-emerald-600 text-emerald-200 px-4 py-3 rounded">{{ notice() }}</div>
<div *ngIf="error()" class="mb-4 bg-red-900/30 border border-red-600 text-red-200 px-4 py-3 rounded">{{ error() }}</div>
<div class="grid grid-cols-1 gap-6">
<!-- General -->
<form (submit)="onSave($event)" class="space-y-6">
<div class="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-700 flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-slate-100">{{ 'preferences.general' | t : 'Préférences générales' }}</h3>
<p class="text-sm text-slate-400">{{ 'preferences.general_hint' | t : 'Personnalisez votre expérience' }}</p>
</div>
<button [disabled]="saving()" class="px-4 py-2 rounded bg-red-600 hover:bg-red-500 disabled:opacity-50">{{ 'btn.save' | t : 'Enregistrer' }}</button>
</div>
<div class="p-5 grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<label class="block text-sm text-slate-400 mb-1">{{ 'label.language' | t : 'Langue' }}</label>
<select class="w-full rounded bg-slate-900 text-slate-100 border border-slate-700 px-3 py-2" [ngModel]="language()" (ngModelChange)="language.set($event)" name="language">
<option value="en">English</option>
<option value="fr">Français</option>
</select>
</div>
<div>
<label class="block text-sm text-slate-400 mb-1">{{ 'label.defaultProvider' | t : 'Fournisseur par défaut' }}</label>
<select class="w-full rounded bg-slate-900 text-slate-100 border border-slate-700 px-3 py-2" [ngModel]="defaultProvider()" (ngModelChange)="defaultProvider.set($event)" name="provider">
<option *ngFor="let p of providers()" [value]="p.id">{{ p.label }}</option>
</select>
</div>
<div>
<label class="block text-sm text-slate-400 mb-1">{{ 'label.theme' | t : 'Thème' }}</label>
<select class="w-full rounded bg-slate-900 text-slate-100 border border-slate-700 px-3 py-2" [ngModel]="theme()" (ngModelChange)="theme.set($event)" name="theme">
<option *ngFor="let t of themes" [value]="t">{{ t | titlecase }}</option>
</select>
</div>
<div>
<label class="block text-sm text-slate-400 mb-1">{{ 'label.quality' | t : 'Qualité par défaut' }}</label>
<select class="w-full rounded bg-slate-900 text-slate-100 border border-slate-700 px-3 py-2" [ngModel]="videoQuality()" (ngModelChange)="videoQuality.set($event)" name="quality">
<option *ngFor="let q of qualities" [value]="q">{{ q }}</option>
</select>
</div>
<div>
<label class="block text-sm text-slate-400 mb-1">{{ 'label.region' | t : 'Région' }}</label>
<select class="w-full rounded bg-slate-900 text-slate-100 border border-slate-700 px-3 py-2" [ngModel]="region()" (ngModelChange)="region.set($event)" name="region">
<option *ngFor="let r of regions" [value]="r">{{ ('region.' + r) | t }}</option>
</select>
</div>
</div>
</div>
</form>
<!-- PeerTube Instances (below general) -->
<section class="space-y-4">
<div class="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-700">
<h3 class="text-lg font-semibold text-slate-100">PeerTube</h3>
<p class="text-sm text-slate-400">{{ 'preferences.peertube_hint' | t : 'Gérez vos instances PeerTube' }}</p>
</div>
<div class="p-5 space-y-4">
<form (submit)="addPeerTubeInstance($event)" class="flex items-center gap-3">
<input type="text" name="peertube"
class="flex-1 rounded bg-slate-900 text-slate-100 border border-slate-700 px-3 py-2"
[placeholder]="'preferences.add_instance_placeholder' | t : 'ex: example.org'"
[ngModel]="newPeerTubeInstance()" (ngModelChange)="newPeerTubeInstance.set($event)" />
<button type="submit" class="px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-white">{{ 'btn.add' | t : 'Ajouter' }}</button>
</form>
<div *ngIf="(peerTubeInstances() || []).length === 0" class="text-sm text-slate-400">{{ 'preferences.no_instances' | t : 'Aucune instance pour le moment.' }}</div>
<ul class="divide-y divide-slate-700 rounded border border-slate-700 overflow-hidden">
<li *ngFor="let host of peerTubeInstances()" class="flex items-center justify-between px-4 py-2 bg-slate-900">
<div class="flex items-center gap-2">
<span class="inline-block w-2 h-2 rounded-full"
[ngClass]="{ 'bg-emerald-400': activePeerTubeInstance() === host, 'bg-slate-600': activePeerTubeInstance() !== host }"></span>
<span class="text-slate-200">{{ host }}</span>
</div>
<div class="flex items-center gap-2">
<button type="button" (click)="setActivePeerTube(host)"
class="px-2 py-1 rounded border"
[ngClass]="{ 'border-emerald-500 text-emerald-300': activePeerTubeInstance() === host, 'border-slate-600 text-slate-300 hover:bg-slate-800': activePeerTubeInstance() !== host }">
{{ activePeerTubeInstance() === host ? ('preferences.active' | t : 'Actif') : ('preferences.set_active' | t : 'Définir actif') }}
</button>
<button type="button" (click)="removePeerTubeInstance(host)" class="px-2 py-1 rounded bg-red-600 hover:bg-red-500 text-white">{{ 'btn.remove' | t : 'Supprimer' }}</button>
</div>
</li>
</ul>
</div>
</div>
</section>
</div>
</div>

View File

@ -0,0 +1,134 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { UserService, UserPreferences } from '../../../services/user.service';
import { InstanceService, Provider } from '../../../services/instance.service';
import { firstValueFrom } from 'rxjs';
import { I18nService } from '../../../services/i18n.service';
import { TranslatePipe } from '../../../pipes/translate.pipe';
@Component({
selector: 'app-preferences',
standalone: true,
templateUrl: './preferences.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule, TranslatePipe]
})
export class PreferencesComponent {
private user = inject(UserService);
instances = inject(InstanceService);
private i18n = inject(I18nService);
// Form model
language = signal<string>('en');
defaultProvider = signal<Provider>('youtube');
theme = signal<string>('system');
videoQuality = signal<string>('auto');
region = signal<string>('US');
saving = signal<boolean>(false);
notice = signal<string | null>(null);
error = signal<string | null>(null);
providers = this.instances.providers; // signal of ProviderInfo[]
regions = ['US', 'FR', 'BR', 'DE', 'GB', 'CA'];
themes = ['system', 'light', 'dark', 'black', 'blue'];
qualities = ['auto', '720p', '1080p'];
// PeerTube instances management
newPeerTubeInstance = signal<string>('');
peerTubeInstances = this.instances.peerTubeInstances; // signal<string[]>
activePeerTubeInstance = this.instances.activePeerTubeInstance; // signal<string>
constructor() {
// Load current preferences into form
firstValueFrom(this.user.loadPreferences()).then((prefs) => this.applyPrefs(prefs)).catch(() => {});
}
private applyPrefs(p: UserPreferences | null) {
if (!p) return;
if (p.language) this.language.set(p.language);
// apply language immediately
if (p.language) this.i18n.setLanguage(p.language);
if (p.defaultProvider) this.defaultProvider.set(p.defaultProvider as Provider);
if (p.theme) this.theme.set(p.theme);
if (p.videoQuality) this.videoQuality.set(p.videoQuality);
if (p.region) this.region.set(p.region);
// Apply theme immediately on load
try {
document.documentElement.setAttribute('data-theme', p.theme || 'system');
localStorage.setItem('newtube.theme', p.theme || 'system');
} catch {}
}
async onSave(ev: Event) {
ev.preventDefault();
this.saving.set(true);
this.notice.set(null);
try {
const patch: Partial<UserPreferences> = {
language: this.language(),
defaultProvider: this.defaultProvider(),
theme: this.theme(),
videoQuality: this.videoQuality(),
region: this.region(),
};
const prefs = await firstValueFrom(this.user.updatePreferences(patch));
// Also apply immediately to runtime (provider/region/theme)
if (prefs.defaultProvider) this.instances.setSelectedProvider(prefs.defaultProvider as Provider);
if (prefs.region) this.instances.setRegion(prefs.region);
if (prefs.language) this.i18n.setLanguage(prefs.language);
try {
document.documentElement.setAttribute('data-theme', prefs.theme || 'system');
localStorage.setItem('newtube.theme', prefs.theme || 'system');
} catch {}
this.notice.set('Preferences saved.');
} catch {
this.notice.set('Failed to save preferences.');
} finally {
this.saving.set(false);
}
}
// ---- PeerTube instances helpers ----
private isValidDomain(host: string): boolean {
const v = (host || '').trim().toLowerCase();
// Basic domain validation (no protocol, no path)
return !!v && /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(v) && !/^https?:\/\//i.test(v) && !/[\/\s]/.test(v);
}
addPeerTubeInstance(ev?: Event) {
ev?.preventDefault();
this.error.set(null);
const raw = (this.newPeerTubeInstance() || '').trim();
if (!this.isValidDomain(raw)) {
this.error.set('Enter a valid domain like "example.org" (no http://, no path).');
return;
}
const domain = raw.toLowerCase();
const list = [...(this.peerTubeInstances() || [])];
if (list.includes(domain)) {
this.error.set('This instance is already in the list.');
return;
}
list.push(domain);
this.instances.setPeerTubeInstances(list);
this.newPeerTubeInstance.set('');
this.notice.set('PeerTube instance added.');
}
removePeerTubeInstance(domain: string) {
const list = (this.peerTubeInstances() || []).filter(d => d !== domain);
this.instances.setPeerTubeInstances(list);
if (this.activePeerTubeInstance() === domain) {
const next = list[0] || '';
if (next) this.instances.setActivePeerTubeInstance(next);
}
this.notice.set('PeerTube instance removed.');
}
setActivePeerTube(domain: string) {
this.instances.setActivePeerTubeInstance(domain);
this.notice.set(`Active PeerTube instance set to ${domain}.`);
}
}

View File

@ -0,0 +1,33 @@
<div class="container mx-auto p-4 sm:p-6">
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">Sessions</h2>
<div *ngIf="error()" class="mb-4 bg-amber-900/40 border border-amber-600 text-amber-200 p-3 rounded">{{ error() }}</div>
<div class="flex items-center gap-3 mb-4">
<button class="px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-white" (click)="reload()" [disabled]="loading()">Reload</button>
<button class="px-4 py-2 rounded bg-red-600 hover:bg-red-500 text-white" (click)="logoutAll()" [disabled]="loading()">Logout all devices</button>
</div>
<div *ngIf="loading()" class="text-slate-400">Loading...</div>
<div *ngIf="!loading() && items().length === 0" class="text-slate-400">No active sessions.</div>
<ul class="space-y-3">
<li *ngFor="let s of items()" class="p-4 bg-slate-800 rounded flex items-start justify-between gap-4">
<div class="text-sm text-slate-200">
<div><span class="text-slate-400">Session ID:</span> <code>{{ s.id }}</code></div>
<div><span class="text-slate-400">User Agent:</span> {{ s.userAgent || '—' }}</div>
<div><span class="text-slate-400">Device:</span> {{ s.deviceInfo || '—' }}</div>
<div><span class="text-slate-400">IP:</span> {{ s.ip || '—' }}</div>
<div><span class="text-slate-400">Remember me:</span> {{ s.isRemember ? 'Yes' : 'No' }}</div>
<div><span class="text-slate-400">Created:</span> {{ s.createdAt || '—' }}</div>
<div><span class="text-slate-400">Last seen:</span> {{ s.lastSeenAt || '—' }}</div>
<div><span class="text-slate-400">Expires:</span> {{ s.expiresAt || '—' }}</div>
<div *ngIf="s.revokedAt"><span class="text-slate-400">Revoked:</span> {{ s.revokedAt }}</div>
</div>
<div class="shrink-0">
<button class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-white" (click)="revoke(s.id)">Revoke</button>
</div>
</li>
</ul>
</div>

View File

@ -0,0 +1,57 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AuthService, SessionInfo } from '../../../services/auth.service';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'app-sessions',
standalone: true,
templateUrl: './sessions.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule]
})
export class SessionsComponent {
private auth = inject(AuthService);
loading = signal<boolean>(false);
items = signal<SessionInfo[]>([]);
error = signal<string | null>(null);
constructor() {
this.reload();
}
reload() {
this.loading.set(true);
this.error.set(null);
this.auth.listSessions().subscribe({
next: (list) => {
this.items.set(list || []);
this.loading.set(false);
},
error: (e: any) => {
const status = (e && typeof e.status === 'number') ? e.status : 0;
if (status === 401) {
this.error.set('Please login to view sessions.');
} else {
this.error.set('Failed to load sessions.');
}
this.loading.set(false);
}
});
}
async revoke(id: string) {
try {
await firstValueFrom(this.auth.revokeSession(id));
this.items.update(arr => arr.filter(s => s.id !== id));
} catch {}
}
async logoutAll() {
try {
await firstValueFrom(this.auth.logout(true));
this.items.set([]);
} catch {}
}
}

View File

@ -0,0 +1,32 @@
<div class="min-h-[calc(100vh-64px)] flex items-center justify-center py-10">
<div class="w-full max-w-md bg-slate-800 rounded-xl shadow-xl p-8">
<div class="flex items-center gap-2 mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-red-500" viewBox="0 0 24 24" fill="currentColor"><path d="M10,15.6l5.3-3.3L10,9V15.6z M21.8,8.1c-0.2-0.8-0.9-1.4-1.7-1.7C18.2,6,12,6,12,6s-6.2,0-8.1,0.5 c-0.8,0.2-1.4,0.9-1.7,1.7C2,9.9,2,12,2,12s0,2.1,0.5,3.9c0.2,0.8,0.9,1.4,1.7,1.7C6.2,18,12,18,12,18s6.2,0,8.1-0.5 c0.8-0.2,1.4-0.9,1.7-1.7c0.5-1.8,0.5-3.9,0.5-3.9S22.2,9.9,21.8,8.1z"/></svg>
<h1 class="text-2xl font-bold">Sign in to NewTube</h1>
</div>
<form (submit)="$event.preventDefault(); submit()" class="space-y-4">
<div>
<label class="block text-sm text-slate-300 mb-1">Username (or Email)</label>
<input [ngModel]="username()" (ngModelChange)="username.set($event)" name="username" autocomplete="username" class="w-full bg-slate-900 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" placeholder="yourname or email@example.com">
</div>
<div>
<label class="block text-sm text-slate-300 mb-1">Password</label>
<input [ngModel]="password()" (ngModelChange)="password.set($event)" type="password" name="password" autocomplete="current-password" class="w-full bg-slate-900 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" placeholder="••••••••">
</div>
<label class="flex items-center gap-2 text-sm text-slate-300">
<input type="checkbox" [ngModel]="remember()" (ngModelChange)="remember.set($event)" name="remember" class="accent-red-600">
<span>Remember me</span>
</label>
<div *ngIf="error()" class="bg-red-900/50 border border-red-600 text-red-200 rounded px-3 py-2 text-sm">{{ error() }}</div>
<button [disabled]="busy()" class="w-full py-2 rounded bg-red-600 hover:bg-red-500 disabled:opacity-60">Sign in</button>
</form>
<div class="mt-4 text-sm text-slate-300">
Don't have an account?
<a routerLink="/auth/register" class="text-red-400 hover:text-red-300">Create one</a>
</div>
</div>
</div>

View File

@ -0,0 +1,59 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service';
import { UserService } from '../../../services/user.service';
import { InstanceService } from '../../../services/instance.service';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'app-login-page',
standalone: true,
templateUrl: './login.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule, RouterLink]
})
export class LoginComponent {
private auth = inject(AuthService);
private userService = inject(UserService);
private instances = inject(InstanceService);
private router = inject(Router);
username = signal('');
password = signal('');
remember = signal(true);
error = signal<string | null>(null);
busy = signal(false);
async submit() {
this.error.set(null);
this.busy.set(true);
try {
const u = this.username().trim();
const p = this.password();
if (!u || !p) {
this.error.set('Username (or Email) and password are required.');
return;
}
await firstValueFrom(this.auth.login(u, p, this.remember()));
try {
const prefs = await firstValueFrom(this.userService.loadPreferences());
if (prefs) {
if (prefs.defaultProvider) this.instances.setSelectedProvider(prefs.defaultProvider as any);
if (prefs.region) this.instances.setRegion(prefs.region);
try { document.documentElement.setAttribute('data-theme', prefs.theme || 'system'); } catch {}
}
} catch {}
this.router.navigate(['/']);
} catch (e: any) {
const status = (e && typeof e.status === 'number') ? e.status : 0;
const serverMsg = (e && e.error && (e.error.error || e.error.message)) || null;
if (status === 401) this.error.set(serverMsg || 'Invalid credentials.');
else if (status === 429) this.error.set('Too many attempts. Please wait a minute and try again.');
else this.error.set(serverMsg || 'Login failed.');
} finally {
this.busy.set(false);
}
}
}

View File

@ -0,0 +1,32 @@
<div class="min-h-[calc(100vh-64px)] flex items-center justify-center py-10">
<div class="w-full max-w-md bg-slate-800 rounded-xl shadow-xl p-8">
<div class="flex items-center gap-2 mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-red-500" viewBox="0 0 24 24" fill="currentColor"><path d="M10,15.6l5.3-3.3L10,9V15.6z M21.8,8.1c-0.2-0.8-0.9-1.4-1.7-1.7C18.2,6,12,6,12,6s-6.2,0-8.1,0.5 c-0.8,0.2-1.4,0.9-1.7,1.7C2,9.9,2,12,2,12s0,2.1,0.5,3.9c0.2,0.8,0.9,1.4,1.7,1.7C6.2,18,12,18,12,18s6.2,0,8.1-0.5 c0.8-0.2,1.4-0.9,1.7-1.7c0.5-1.8,0.5-3.9,0.5-3.9S22.2,9.9,21.8,8.1z"/></svg>
<h1 class="text-2xl font-bold">Create your account</h1>
</div>
<form (submit)="$event.preventDefault(); submit()" class="space-y-4">
<div>
<label class="block text-sm text-slate-300 mb-1">Username (or Email)</label>
<input [ngModel]="username()" (ngModelChange)="username.set($event)" name="username" autocomplete="username" class="w-full bg-slate-900 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" placeholder="yourname (or leave empty to use email)">
</div>
<div>
<label class="block text-sm text-slate-300 mb-1">Email (optional)</label>
<input [ngModel]="email()" (ngModelChange)="email.set($event)" type="email" name="email" autocomplete="email" class="w-full bg-slate-900 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" placeholder="email@example.com">
</div>
<div>
<label class="block text-sm text-slate-300 mb-1">Password</label>
<input [ngModel]="password()" (ngModelChange)="password.set($event)" type="password" name="new-password" autocomplete="new-password" class="w-full bg-slate-900 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500" placeholder="••••••••">
</div>
<div *ngIf="error()" class="bg-red-900/50 border border-red-600 text-red-200 rounded px-3 py-2 text-sm">{{ error() }}</div>
<button [disabled]="busy()" class="w-full py-2 rounded bg-red-600 hover:bg-red-500 disabled:opacity-60">Create account</button>
</form>
<div class="mt-4 text-sm text-slate-300">
Already have an account?
<a routerLink="/auth/login" class="text-red-400 hover:text-red-300">Sign in</a>
</div>
</div>
</div>

View File

@ -0,0 +1,62 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service';
import { UserService } from '../../../services/user.service';
import { InstanceService } from '../../../services/instance.service';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'app-register-page',
standalone: true,
templateUrl: './register.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule, RouterLink]
})
export class RegisterComponent {
private auth = inject(AuthService);
private userService = inject(UserService);
private instances = inject(InstanceService);
private router = inject(Router);
username = signal('');
email = signal('');
password = signal('');
error = signal<string | null>(null);
busy = signal(false);
async submit() {
this.error.set(null);
this.busy.set(true);
try {
const uRaw = this.username().trim();
const eRaw = this.email().trim();
const p = this.password();
const username = uRaw || eRaw; // allow email as identifier
if (!username || !p) {
this.error.set('Username (or Email) and password are required.');
return;
}
await firstValueFrom(this.auth.register(username, p, eRaw || undefined));
try {
const prefs = await firstValueFrom(this.userService.loadPreferences());
if (prefs) {
if (prefs.defaultProvider) this.instances.setSelectedProvider(prefs.defaultProvider as any);
if (prefs.region) this.instances.setRegion(prefs.region);
try { document.documentElement.setAttribute('data-theme', prefs.theme || 'system'); } catch {}
}
} catch {}
this.router.navigate(['/']);
} catch (e: any) {
const status = (e && typeof e.status === 'number') ? e.status : 0;
const serverMsg = (e && e.error && (e.error.error || e.error.message)) || null;
if (status === 409) this.error.set(serverMsg || 'Username already exists.');
else if (status === 400) this.error.set(serverMsg || 'Username (or Email) and password are required.');
else if (status === 429) this.error.set('Too many attempts. Please wait a minute and try again.');
else this.error.set(serverMsg || 'Registration failed.');
} finally {
this.busy.set(false);
}
}
}

View File

@ -0,0 +1,222 @@
<header class="bg-slate-800/50 backdrop-blur-sm fixed top-0 left-0 right-0 z-50 shadow-lg w-full border-b border-slate-700/60">
<div class="w-full px-0 py-3 grid items-center gap-4 relative grid-cols-[auto_1fr_auto]">
<!-- Left: burger + logo -->
<div class="flex items-center gap-3 shrink-0 pl-3 md:pl-4">
<button (click)="menuToggle.emit()" class="p-2 rounded-full hover:bg-slate-700 focus:outline-none" aria-label="Toggle menu">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-200" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 6h18v2H3V6zm0 5h18v2H3v-2zm0 5h18v2H3v-2z"/>
</svg>
</button>
<a routerLink="/" class="flex items-center space-x-2 shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-red-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M10,15.6l5.3-3.3L10,9V15.6z M21.8,8.1c-0.2-0.8-0.9-1.4-1.7-1.7C18.2,6,12,6,12,6s-6.2,0-8.1,0.5 c-0.8,0.2-1.4,0.9-1.7,1.7C2,9.9,2,12,2,12s0,2.1,0.5,3.9c0.2,0.8,0.9,1.4,1.7,1.7C6.2,18,12,18,12,18s6.2,0,8.1-0.5 c0.8-0.2,1.4-0.9,1.7-1.7c0.5-1.8,0.5-3.9,0.5-3.9S22.2,9.9,21.8,8.1z"/>
</svg>
<h1 class="text-2xl font-bold tracking-tight text-white">NewTube</h1>
</a>
</div>
<!-- Center: search (grid-centered, avoids overlap) -->
<form class="justify-self-center mx-auto w-full max-w-2xl md:max-w-3xl lg:max-w-4xl px-4 relative group" (submit)="onSubmitSearch($event, searchInput)">
<input #searchInput type="search" [placeholder]="('search.placeholder' | t)" aria-label="Search"
(focus)="onSearchFocus()" (input)="onSearchInput(searchInput)" (blur)="onSearchBlur()" (keydown)="onSearchKeydown($event, searchInput)"
(search)="onSearchCleared(searchInput)"
[ngClass]="{
'bg-white text-slate-900 placeholder-slate-500 ring-slate-300 hover:bg-slate-50': isLightTheme(),
'bg-slate-700/80 text-slate-200 placeholder-slate-400 ring-slate-600/50 hover:bg-slate-700/70': isDarkTheme(),
'bg-black text-slate-200 placeholder-slate-400 ring-slate-800/50 hover:bg-slate-900': isBlackTheme(),
'bg-blue-950/80 text-blue-100 placeholder-blue-300 ring-blue-900/60 hover:bg-blue-900/70': isBlueTheme()
}"
class="w-full rounded-lg py-2.5 px-4 pl-10 pr-16 focus:outline-none ring-1 focus:ring-2 focus:ring-red-500/70 transition-all duration-200 shadow-sm">
<svg class="w-5 h-5 absolute left-6 md:left-5 top-1/2 -translate-y-1/2"
[ngClass]="{
'text-slate-500': isLightTheme(),
'text-slate-400': isDarkTheme(),
'text-slate-300': isBlackTheme(),
'text-blue-200': isBlueTheme()
}"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<!-- Keyboard shortcut hint (only when no provider indicator) -->
<div *ngIf="!providerContextLabel()" class="absolute right-6 top-1/2 -translate-y-1/2 hidden md:flex items-center gap-1 text-xs select-none"
[ngClass]="{
'text-slate-700/80': isLightTheme(),
'text-slate-300/80': isDarkTheme() || isBlackTheme(),
'text-blue-200/80': isBlueTheme()
}">
<kbd [ngClass]="{
'bg-slate-200/80 border-slate-300 text-slate-700': isLightTheme(),
'bg-slate-700/80 border-slate-600/60': isDarkTheme(),
'bg-slate-900/80 border-slate-800/60 text-slate-300': isBlackTheme(),
'bg-blue-800/80 border-blue-700/60 text-blue-200': isBlueTheme()
}" class="px-1.5 py-0.5 rounded border">Ctrl</kbd>
<span>+</span>
<kbd [ngClass]="{
'bg-slate-200/80 border-slate-300 text-slate-700': isLightTheme(),
'bg-slate-700/80 border-slate-600/60 text-slate-300': isDarkTheme(),
'bg-slate-900/80 border-slate-800/60 text-slate-300': isBlackTheme(),
'bg-blue-800/80 border-blue-700/60 text-blue-200': isBlueTheme()
}" class="px-1.5 py-0.5 rounded border">K</kbd>
</div>
<!-- Suggestions dropdown -->
@if (suggestionsOpen() && suggestionItems().length > 0) {
<div class="absolute z-50 mt-2 left-1/2 -translate-x-1/2 w-full max-w-2xl md:max-w-3xl lg:max-w-4xl rounded-xl shadow-2xl overflow-hidden"
[ngClass]="{
'bg-white border border-slate-200 text-slate-900': isLightTheme(),
'bg-slate-800/95 border border-slate-700 text-slate-200': isDarkTheme(),
'bg-black/95 border border-slate-900 text-slate-200': isBlackTheme(),
'bg-blue-950/90 border border-blue-800 text-blue-100': isBlueTheme(),
'backdrop-blur-md': !isLightTheme()
}">
<ul class="max-h-[70vh] overflow-y-auto py-1">
@for (item of suggestionItems(); track $index; let i = $index) {
<!-- Divider before first generated suggestion -->
@if (isFirstGenerated(i)) {
<li class="px-4 py-2 text-xs uppercase tracking-wide border-t border-b"
[ngClass]="{
'text-slate-500 border-slate-100': isLightTheme(),
'text-slate-400 border-slate-700': isDarkTheme(),
'text-slate-400 border-slate-800': isBlackTheme(),
'text-blue-300 border-blue-800': isBlueTheme()
}">Suggestions</li>
}
<li (mouseenter)="highlightedIndex.set(i)">
<button type="button" (mousedown)="pickSuggestion(item.text, searchInput)"
class="w-full text-left px-4 py-2 flex items-center gap-3 transition-colors duration-150"
[ngClass]="{
'hover:bg-slate-100': isLightTheme(),
'hover:bg-slate-700/70': isDarkTheme(),
'hover:bg-slate-900': isBlackTheme(),
'hover:bg-blue-800/60': isBlueTheme(),
'bg-slate-200': isLightTheme() && highlightedIndex() === i,
'bg-slate-700/50': isDarkTheme() && highlightedIndex() === i,
'bg-slate-900/70': isBlackTheme() && highlightedIndex() === i,
'bg-blue-800/50': isBlueTheme() && highlightedIndex() === i,
'text-slate-900': isLightTheme(),
'text-slate-200': isDarkTheme() || isBlackTheme(),
'text-blue-100': isBlueTheme()
}">
<!-- Clock for history, magnifier for generated -->
<svg *ngIf="item.source === 'history'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5"
[ngClass]="{
'text-slate-500': isLightTheme(),
'text-slate-400': isDarkTheme(),
'text-slate-300': isBlackTheme(),
'text-blue-200': isBlueTheme()
}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg *ngIf="item.source === 'generated'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5"
[ngClass]="{
'text-slate-500': isLightTheme(),
'text-slate-400': isDarkTheme(),
'text-slate-300': isBlackTheme(),
'text-blue-200': isBlueTheme()
}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span class="truncate">{{ item.text }}</span>
</button>
</li>
}
</ul>
</div>
}
</form>
<!-- Right: user -->
<div class="justify-self-end flex items-center gap-3 pr-3 md:pr-4">
<!-- Provider context indicator placed between search and user area -->
<div *ngIf="providerContextLabel()" class="flex items-center gap-1 text-xs">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-yellow-400/30 border border-yellow-400/40 shadow-sm"
[ngClass]="{ 'text-black': isLightTheme(), 'text-yellow-200': !isLightTheme() }">
<span class="inline-block w-2 h-2 rounded-full bg-yellow-300 animate-pulse"></span>
<span>{{ providerContextLabel() }}</span>
</span>
</div>
<div class="h-6 w-px bg-slate-700/60 mx-2"></div>
<!-- Auth / User Menu Area -->
<div *ngIf="!user(); else userMenu" class="flex items-center gap-2">
<a routerLink="/auth/login" class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-white">{{ 'btn.login' | t }}</a>
<a routerLink="/auth/register" class="px-3 py-1 rounded bg-red-600 hover:bg-red-500 text-white">{{ 'btn.register' | t }}</a>
</div>
<ng-template #userMenu>
<div class="relative" #userMenuContainer>
<!-- Avatar Button -->
<button (click)="toggleUserMenu()" class="flex items-center gap-2 p-1 rounded-full hover:bg-slate-700">
<div class="h-8 w-8 rounded-full flex items-center justify-center text-sm font-semibold border"
[ngClass]="{
'bg-slate-200 text-slate-800 border-slate-300': isLightTheme(),
'bg-slate-600 text-white border-slate-500': isDarkTheme(),
'bg-slate-800 text-slate-200 border-slate-700': isBlackTheme(),
'bg-blue-900 text-blue-100 border-blue-800': isBlueTheme()
}">
{{ (user()?.username || user()?.email || 'U') | slice:0:1 | uppercase }}
</div>
</button>
<!-- Dropdown Menu -->
@if (userMenuOpen()) {
<div class="absolute right-0 mt-2 w-64 bg-slate-900 border border-slate-700 rounded-xl shadow-xl overflow-hidden z-50">
<div class="px-4 py-3 border-b border-slate-700">
<div class="font-semibold text-slate-100">{{ user()?.username || user()?.email }}</div>
<div class="text-xs text-slate-400 truncate">{{ user()?.email }}</div>
</div>
<div class="py-1">
<a routerLink="/account/preferences" class="block px-4 py-2 text-sm text-slate-200 hover:bg-slate-800">{{ 'menu.preferences' | t }}</a>
<div class="px-4 py-2 text-xs uppercase tracking-wide text-slate-500">{{ 'menu.appearance' | t }}</div>
<div class="px-3 pb-2 grid grid-cols-4 gap-2">
<!-- Black -->
<button (click)="setTheme('black')"
[ngClass]="{ 'ring-2': currentTheme() === 'black', 'ring-red-500': currentTheme() === 'black' }"
class="px-2 py-2 text-xs rounded bg-slate-800 hover:bg-slate-700 flex items-center justify-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="9"/></svg>
<span>{{ 'theme.black' | t }}</span>
</button>
<!-- Dark -->
<button (click)="setTheme('dark')"
[ngClass]="{ 'ring-2': currentTheme() === 'dark', 'ring-red-500': currentTheme() === 'dark' }"
class="px-2 py-2 text-xs rounded bg-slate-800 hover:bg-slate-700 flex items-center justify-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 1 0 9.79 9.79Z"/></svg>
<span>{{ 'theme.dark' | t }}</span>
</button>
<!-- Blue -->
<button (click)="setTheme('blue')"
[ngClass]="{ 'ring-2': currentTheme() === 'blue', 'ring-red-500': currentTheme() === 'blue' }"
class="px-2 py-2 text-xs rounded bg-slate-800 hover:bg-slate-700 flex items-center justify-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2s7 7.58 7 12a7 7 0 1 1-14 0C5 9.58 12 2 12 2Z"/></svg>
<span>{{ 'theme.blue' | t }}</span>
</button>
<!-- Light -->
<button (click)="setTheme('light')"
[ngClass]="{ 'ring-2': currentTheme() === 'light', 'ring-red-500': currentTheme() === 'light' }"
class="px-2 py-2 text-xs rounded bg-slate-800 hover:bg-slate-700 flex items-center justify-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 18a6 6 0 1 0 0-12 6 6 0 0 0 0 12Zm0-16a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V3a1 1 0 0 1 1-1Zm0 18a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1Zm9-9a1 1 0 0 1 1 1h-2a1 1 0 1 1 0-2h2a1 1 0 0 1-1 1ZM4 12a1 1 0 0 1 1-1H3a1 1 0 1 1 0 2h2a1 1 0 0 1-1-1Zm12.95 6.364a1 1 0 0 1 1.414 1.414l-1.415 1.415a1 1 0 1 1-1.414-1.415l1.415-1.414ZM7.05 4.222a1 1 0 1 1 1.414-1.415L9.88 4.222A1 1 0 0 1 8.465 5.636L7.05 4.222zm9.9-1.415a1 1 0 1 1 1.414 1.415L17.95 5.636A1 1 0 0 1 16.536 4.22l1.414-1.414ZM7.05 19.778 5.636 21.19a1 1 0 0 1-1.414-1.414L5.636 18.364A1 1 0 0 1 7.05 19.78Z"/></svg>
<span>{{ 'theme.light' | t }}</span>
</button>
</div>
<button (click)="onLogout()" class="w-full text-left px-4 py-2 text-sm text-red-300 hover:bg-red-900/30">{{ 'menu.logout' | t }}</button>
</div>
</div>
}
</div>
</ng-template>
</div>
</div>
</header>
<!-- PeerTube Instance Editor Modal -->
<div *ngIf="editingPeerTubeInstances()" class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
<div class="bg-gray-800 rounded-lg p-6 w-full max-w-md">
<h2 class="text-xl font-bold mb-4">Edit PeerTube Instances</h2>
<p class="text-sm text-gray-400 mb-4">Enter one instance domain per line.</p>
<textarea [(ngModel)]="peerTubeInstancesInput" class="w-full h-48 bg-gray-900 text-white rounded p-2 focus:outline-none focus:ring-2 focus:ring-red-500"></textarea>
<div class="mt-4 flex justify-end gap-3">
<button (click)="editingPeerTubeInstances.set(false)" class="px-4 py-2 rounded bg-gray-600 hover:bg-gray-500">Cancel</button>
<button (click)="savePeerTubeInstances()" class="px-4 py-2 rounded bg-red-600 hover:bg-red-500">Save</button>
</div>
</div>
</div>

View File

@ -0,0 +1,550 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal, Output, EventEmitter, HostListener, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterLink, NavigationEnd } from '@angular/router';
import { InstanceService, Provider } from '../../services/instance.service';
import { FormsModule } from '@angular/forms';
import { AuthService } from '../../services/auth.service';
import { UserService } from '../../services/user.service';
import { firstValueFrom } from 'rxjs';
import { TranslatePipe } from '../../pipes/translate.pipe';
import { I18nService } from '../../services/i18n.service';
import { ThemesService } from '../../services/themes.service';
import { HistoryService, SearchHistoryItem } from '../../services/history.service';
@Component({
selector: 'app-header',
templateUrl: './header.component.html',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
RouterLink,
CommonModule,
FormsModule,
TranslatePipe
]
})
export class HeaderComponent {
private router = inject(Router);
instances = inject(InstanceService);
private auth = inject(AuthService);
private userService = inject(UserService);
private i18n = inject(I18nService);
private themes = inject(ThemesService);
private history = inject(HistoryService);
@ViewChild('searchInput', { static: false }) searchInputRef?: ElementRef<HTMLInputElement>;
@ViewChild('userMenuContainer', { static: false }) userMenuContainerRef?: ElementRef<HTMLElement>;
searchQuery = '';
editingPeerTubeInstances = signal(false);
peerTubeInstancesInput = '';
// Search suggestions dropdown state
suggestionsOpen = signal(false);
recentSearches = signal<SearchHistoryItem[]>([]);
highlightedIndex = signal<number>(-1);
// Suggestion item shape: history vs generated
readonly suggestionItems = computed<{ text: string; source: 'history' | 'generated' }[]>(() => {
type Item = { text: string; source: 'history' | 'generated' };
const q = (this.searchQuery || '').trim();
const provider = this.selectedProvider();
const fromHistory = (this.recentSearches() || [])
.map(it => (it.query || '').trim())
.filter(Boolean);
// 1) Base list from history
let historyList: string[];
if (!q) {
historyList = fromHistory.slice(0, 15);
} else {
const lower = q.toLowerCase();
historyList = fromHistory.filter(txt => txt.toLowerCase().includes(lower)).slice(0, 15);
}
const items: Item[] = historyList.map(text => ({ text, source: 'history' }));
// 2) If we have fewer than 15, fill with generated suggestions
if (items.length < 15) {
const need = 15 - items.length;
const generated = this.generateQuerySuggestions(q, provider);
// Avoid duplicates with history
const existing = new Set(items.map(i => i.text.toLowerCase()));
for (const g of generated) {
const t = (g || '').trim();
if (!t) continue;
if (existing.has(t.toLowerCase())) continue;
items.push({ text: t, source: 'generated' });
if (items.length >= 15) break;
}
}
// Reset highlight if list size changes or becomes empty
const prev = this.highlightedIndex();
if (prev >= items.length) this.highlightedIndex.set(items.length - 1);
if (items.length === 0) this.highlightedIndex.set(-1);
return items;
});
// Generate query suggestions based on the current text and provider
private generateQuerySuggestions(q: string, provider: string | null): string[] {
const base = (q || '').trim();
const suggestions: string[] = [];
// If nothing typed yet, propose popular generic queries per provider context
if (!base) {
// Lightweight provider-tailored seeds
const common = [
'live', 'news', 'best of', 'highlights', 'playlist', 'remix', 'cover', 'tutorial', 'review'
];
if (provider === 'youtube') {
suggestions.push('trending', 'documentary', 'mix', 'lyrics', 'official video');
} else if (provider === 'peertube') {
suggestions.push('open source', 'conference', 'self-hosted');
}
suggestions.push(...common);
return suggestions;
}
// Heuristics: variants that typically help discovery
const quoted = `"${base}"`;
const year = new Date().getFullYear();
const tokens = [
quoted,
`${base} official`,
`${base} live`,
`${base} lyrics`,
`${base} remix`,
`${base} cover`,
`${base} tutorial`,
`${base} review`,
`${base} full album`,
`${base} best of`,
`${base} ${year}`,
`${base} ${year - 1}`
];
if (provider === 'youtube') {
tokens.push(`${base} playlist`, `${base} 4k`, `${base} short`);
}
if (provider === 'rumble') {
tokens.push(`${base} highlights`, `${base} podcast`);
}
// Keep unique and meaningful
const seen = new Set<string>();
for (const t of tokens) {
const key = t.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
suggestions.push(t);
}
if (suggestions.length >= 20) break;
}
return suggestions;
}
readonly providers = computed(() => this.instances.providers());
readonly selectedProvider = computed(() => this.instances.selectedProvider());
readonly user = computed(() => this.auth.currentUser());
// Provider context read from current URL query param (?provider=...)
providerContext = signal<Provider | null>(null);
providerContextLabel = computed(() => {
const ctx = this.providerContext();
if (!ctx) return null;
const p = this.providers().find(x => x.id === ctx);
return p?.label || ctx;
});
// Theme management (global/local)
themeOptions = ['system', 'light', 'dark', 'black', 'blue'];
currentTheme = signal<string>((() => {
try { return localStorage.getItem('newtube.theme') || 'system'; } catch { return 'system'; }
})());
// Login/Register modals state
loginOpen = signal(false);
registerOpen = signal(false);
loginUsername = signal('');
loginPassword = signal('');
rememberMe = signal(true);
registerUsername = signal('');
registerPassword = signal('');
registerEmail = signal('');
authError = signal<string | null>(null);
userMenuOpen = signal(false);
// Sidebar toggle (emits to parent AppComponent)
@Output() menuToggle = new EventEmitter<void>();
onSubmitSearch(ev: Event, input: HTMLInputElement) {
ev.preventDefault();
const q = input.value.trim();
if (!q) return;
const provider = this.selectedProvider();
const theme = this.themes.activeSlug();
const qp: any = { q, provider };
if (theme) qp.theme = theme;
this.router.navigate(['/search'], { queryParams: qp });
}
// Open suggestions and load last 15 searches when focusing the input
onSearchFocus() {
this.suggestionsOpen.set(true);
this.loadRecentSearches();
this.highlightedIndex.set(-1);
}
// Keep suggestions open and update searchQuery as user types
onSearchInput(input: HTMLInputElement) {
this.searchQuery = input.value;
if (!this.suggestionsOpen()) {
this.suggestionsOpen.set(true);
}
// Force refresh of suggestions by updating the signal
this.recentSearches.update(searches => [...searches]);
this.highlightedIndex.set(-1);
}
// Delay closing to allow click on a suggestion
onSearchBlur() {
setTimeout(() => this.suggestionsOpen.set(false), 150);
}
// Pick a suggestion: fill input and submit navigation
pickSuggestion(text: string, input: HTMLInputElement) {
input.value = text;
this.searchQuery = text;
// Submit same as pressing Enter
const provider = this.selectedProvider();
const theme = this.themes.activeSlug();
const qp: any = { q: text, provider };
if (theme) qp.theme = theme;
this.router.navigate(['/search'], { queryParams: qp });
this.suggestionsOpen.set(false);
}
// Keyboard navigation for suggestions
onSearchKeydown(ev: KeyboardEvent, input: HTMLInputElement) {
const items = this.suggestionItems();
if (!items || items.length === 0) return;
const max = items.length - 1;
const current = this.highlightedIndex();
if (ev.key === 'ArrowDown') {
ev.preventDefault();
const next = Math.min(max, current + 1);
this.highlightedIndex.set(next < 0 ? 0 : next);
} else if (ev.key === 'ArrowUp') {
ev.preventDefault();
const next = Math.max(-1, current - 1);
this.highlightedIndex.set(next);
} else if (ev.key === 'Enter') {
if (current >= 0 && current <= max) {
ev.preventDefault();
const chosen = items[current]?.text;
if (chosen) this.pickSuggestion(chosen, input);
}
} else if (ev.key === 'Escape') {
this.suggestionsOpen.set(false);
}
}
// Theme helpers for styling based on current theme
getCurrentTheme(): string {
return this.currentTheme() || 'system';
}
isLightTheme(): boolean {
const t = this.getCurrentTheme();
return t === 'light' || t === 'system';
}
isDarkTheme(): boolean {
const t = this.getCurrentTheme();
return t === 'dark';
}
isBlackTheme(): boolean {
const t = this.getCurrentTheme();
return t === 'black';
}
isBlueTheme(): boolean {
const t = this.getCurrentTheme();
return t === 'blue';
}
getThemeClasses(): { [key: string]: boolean } {
const theme = this.getCurrentTheme();
return {
'theme-light': theme === 'light' || theme === 'system',
'theme-dark': theme === 'dark',
'theme-black': theme === 'black',
'theme-blue': theme === 'blue'
};
}
// Helper to show the divider before the first generated item
isFirstGenerated(index: number): boolean {
const arr = this.suggestionItems();
if (!arr || index < 0 || index >= arr.length) return false;
if (arr[index]?.source !== 'generated') return false;
return index === 0 || arr[index - 1]?.source !== 'generated';
}
private loadRecentSearches() {
try {
this.history.getSearchHistory(15).subscribe({
next: (items) => {
// API likely returns newest first; ensure we keep order and dedupe by query
this.recentSearches.set(items || []);
},
error: () => {}
});
} catch {}
}
ngOnInit() {
// Initialize and keep provider context in sync with URL
this.updateProviderContextFromUrl(this.router.url);
this.router.events.subscribe(evt => {
if (evt instanceof NavigationEnd) {
this.updateProviderContextFromUrl(evt.urlAfterRedirects || evt.url);
}
});
}
private updateProviderContextFromUrl(url: string) {
try {
// Normalize to portion after '#', if present (hash routing)
const hashIdx = url.indexOf('#');
const afterHash = hashIdx >= 0 ? url.substring(hashIdx + 1) : url;
// Prefer query param ?provider=...
const qIdx = afterHash.indexOf('?');
const query = qIdx >= 0 ? afterHash.substring(qIdx + 1) : '';
const params = new URLSearchParams(query);
let provider = (params.get('provider') || '').trim() as Provider;
// If not provided via query, try path format /p/:provider/... (supports hash and non-hash routing)
if (!provider) {
// Normalize to path without query/hash prefix symbols
const pathOnly = (qIdx >= 0 ? afterHash.substring(0, qIdx) : afterHash) || '';
const noHash = pathOnly; // already removed above
const segs = noHash.split('/').filter(s => s.length > 0);
// Look for pattern ['p', ':provider', ...]
const pIdx = segs.indexOf('p');
if (pIdx >= 0 && segs.length > pIdx + 1) {
provider = segs[pIdx + 1] as Provider;
}
}
// Validate against known providers list; if none and we're on Shorts, fall back to selected provider
const known = this.providers().map(p => p.id);
if (provider && known.includes(provider)) {
this.providerContext.set(provider);
} else {
// If current path looks like '/shorts' (hash routing already normalized above)
const pathOnly = (qIdx >= 0 ? afterHash.substring(0, qIdx) : afterHash) || '';
const segs = pathOnly.split('/').filter(s => s.length > 0);
if (segs.length > 0 && segs[0] === 'shorts') {
const fallback = this.selectedProvider();
if (fallback && known.includes(fallback)) {
this.providerContext.set(fallback);
return;
}
}
this.providerContext.set(null);
}
} catch {
this.providerContext.set(null);
}
}
// When the native clear (X) on a search input is clicked, browsers fire a 'search' event.
// If the field becomes empty, navigate back to Home.
onSearchCleared(input: HTMLInputElement) {
const q = (input?.value || '').trim();
if (!q) {
this.router.navigate(['/']);
}
}
focusSearch() {
try {
const el = this.searchInputRef?.nativeElement;
if (el) { el.focus(); el.select(); }
} catch {}
}
@HostListener('document:keydown', ['$event'])
handleGlobalKeydown(ev: KeyboardEvent) {
const isInput = (ev.target as HTMLElement)?.closest('input, textarea, [contenteditable="true"]');
// Ctrl/Cmd+K focuses search
if ((ev.key === 'k' || ev.key === 'K') && (ev.ctrlKey || ev.metaKey)) {
ev.preventDefault();
this.focusSearch();
return;
}
// '/' focuses search when not typing in another input
if (ev.key === '/' && !isInput) {
ev.preventDefault();
this.focusSearch();
return;
}
// Escape blurs search
if (ev.key === 'Escape') {
const el = this.searchInputRef?.nativeElement;
if (el && document.activeElement === el) {
(document.activeElement as HTMLElement)?.blur();
}
}
}
onProviderChange(event: Event) {
const selectedProvider = (event.target as HTMLSelectElement).value as Provider;
this.instances.setSelectedProvider(selectedProvider);
}
toggleUserMenu() {
this.userMenuOpen.update(v => !v);
}
@HostListener('document:click', ['$event'])
onDocumentClick(ev: MouseEvent) {
if (!this.userMenuOpen()) return;
const container = this.userMenuContainerRef?.nativeElement;
const target = ev.target as Node | null;
if (container && target && !container.contains(target)) {
this.userMenuOpen.set(false);
}
}
onPeerTubeInstanceChange(event: Event) {
const selectedInstance = (event.target as HTMLSelectElement).value;
this.instances.setActivePeerTubeInstance(selectedInstance);
}
editPeerTubeInstances() {
this.peerTubeInstancesInput = this.instances.peerTubeInstances().join('\n');
this.editingPeerTubeInstances.set(true);
}
savePeerTubeInstances() {
const instances = this.peerTubeInstancesInput.split('\n').map(i => i.trim()).filter(i => i.length > 0);
this.instances.setPeerTubeInstances(instances);
if (!instances.includes(this.instances.activePeerTubeInstance())) {
this.instances.setActivePeerTubeInstance(instances[0] || '');
}
this.editingPeerTubeInstances.set(false);
}
onThemeChange(event: Event) {
const value = (event.target as HTMLSelectElement | null)?.value || 'system';
this.setTheme(value);
}
setTheme(value: string) {
this.currentTheme.set(value);
try {
document.documentElement.setAttribute('data-theme', value);
localStorage.setItem('newtube.theme', value);
} catch {}
// If logged in, persist to backend preferences (fire-and-forget)
if (this.user()) {
try { this.userService.updatePreferences({ theme: value }).subscribe({ next: () => {}, error: () => {} }); } catch {}
}
}
cycleTheme() {
const order = this.themeOptions;
const cur = this.currentTheme();
const idx = Math.max(0, order.indexOf(cur));
const nextVal = order[(idx + 1) % order.length];
this.setTheme(nextVal);
}
// Auth actions
openLogin() { this.clearAuthForms(); this.loginOpen.set(true); }
openRegister() { this.clearAuthForms(); this.registerOpen.set(true); }
closeModals() { this.loginOpen.set(false); this.registerOpen.set(false); this.authError.set(null); }
private clearAuthForms() {
this.loginUsername.set('');
this.loginPassword.set('');
this.rememberMe.set(true);
this.registerUsername.set('');
this.registerPassword.set('');
this.registerEmail.set('');
this.authError.set(null);
}
async onSubmitLogin(ev: Event, u?: HTMLInputElement, p?: HTMLInputElement, rm?: HTMLInputElement) {
ev.preventDefault();
this.authError.set(null);
try {
const username = (u?.value ?? this.loginUsername()).trim();
const password = p?.value ?? this.loginPassword();
const remember = (rm?.checked != null) ? rm.checked : this.rememberMe();
await firstValueFrom(this.auth.login(username, password, remember));
// Load preferences and apply immediately
const prefs = await firstValueFrom(this.userService.loadPreferences());
if (prefs) {
if (prefs.defaultProvider) this.instances.setSelectedProvider(prefs.defaultProvider as any);
if (prefs.region) this.instances.setRegion(prefs.region);
try {
const t = prefs.theme || 'system';
document.documentElement.setAttribute('data-theme', t);
this.currentTheme.set(t);
localStorage.setItem('newtube.theme', t);
} catch {}
if (prefs.language) this.i18n.setLanguage(prefs.language);
}
this.closeModals();
} catch (e: any) {
const status = (e && typeof e.status === 'number') ? e.status : 0;
const serverMsg = (e && e.error && (e.error.error || e.error.message)) || null;
if (status === 401) this.authError.set(serverMsg || 'Invalid credentials.');
else if (status === 429) this.authError.set('Too many attempts. Please wait a minute and try again.');
else this.authError.set(serverMsg || 'Login failed.');
}
}
async onSubmitRegister(ev: Event, ru?: HTMLInputElement, re?: HTMLInputElement, rp?: HTMLInputElement) {
ev.preventDefault();
this.authError.set(null);
try {
const usernameRaw = (ru?.value ?? this.registerUsername()).trim();
const emailRaw = (re?.value ?? this.registerEmail()).trim();
const password = (rp?.value ?? this.registerPassword());
const username = usernameRaw || emailRaw; // fallback to email when username empty
if (!username || !password) {
this.authError.set('Username (or Email) and password are required.');
return;
}
await firstValueFrom(this.auth.register(username, password, emailRaw || undefined));
const prefs = await firstValueFrom(this.userService.loadPreferences());
if (prefs) {
if (prefs.defaultProvider) this.instances.setSelectedProvider(prefs.defaultProvider as any);
if (prefs.region) this.instances.setRegion(prefs.region);
try {
const t = prefs.theme || 'system';
document.documentElement.setAttribute('data-theme', t);
this.currentTheme.set(t);
localStorage.setItem('newtube.theme', t);
} catch {}
}
this.closeModals();
} catch (e: any) {
const status = (e && typeof e.status === 'number') ? e.status : 0;
const serverMsg = (e && e.error && (e.error.error || e.error.message)) || null;
if (status === 409) this.authError.set(serverMsg || 'Username already exists. Choose another.');
else if (status === 400) this.authError.set(serverMsg || 'Username and password are required.');
else if (status === 429) this.authError.set('Too many attempts. Please wait a minute and try again.');
else this.authError.set(serverMsg || 'Registration failed.');
}
}
async onLogout() {
try { await firstValueFrom(this.auth.logout()); } catch {}
}
}

View File

@ -0,0 +1,57 @@
<div class="container mx-auto p-4 sm:p-6">
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">{{ 'home.trending' | t }}</h2>
@if (notice()) {
<div class="mb-4 bg-amber-900/40 border border-amber-600 text-amber-200 p-3 rounded">
{{ notice() }}
</div>
}
@if (loading()) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
@for (item of [1,2,3,4,5,6,7,8]; track item) {
<div class="animate-pulse bg-slate-800 rounded-lg overflow-hidden">
<div class="w-full h-48 bg-slate-700"></div>
<div class="p-4 space-y-3">
<div class="h-4 bg-slate-700 rounded w-3/4"></div>
<div class="h-4 bg-slate-700 rounded w-1/2"></div>
</div>
</div>
}
</div>
} @else {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-8">
@for (video of trendingVideos(); track video.videoId) {
<a [routerLink]="['/watch', video.videoId]" [queryParams]="watchQueryParams(video)" [state]="{ video }" class="group flex flex-col bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1">
<div class="relative">
<img [src]="video.thumbnail" [alt]="video.title" class="w-full h-48 object-cover transition-transform duration-300 group-hover:scale-105">
<div class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded">
{{ video.duration / 60 | number:'1.0-0' }}:{{ (video.duration % 60) | number:'2.0-0' }}
</div>
</div>
<div class="p-4 flex-grow flex flex-col">
<h3 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 line-clamp-2">{{ video.title }}</h3>
<div class="mt-2 flex items-center space-x-3 text-sm text-slate-400">
<img [src]="video.uploaderAvatar" [alt]="video.uploaderName" class="w-8 h-8 rounded-full">
<span>{{ video.uploaderName }}</span>
</div>
<div class="mt-auto pt-2 text-sm text-slate-400">
<span>{{ formatViews(video.views) }} visionnements</span> &bull; <span>{{ formatRelative(video.uploadedDate) }}</span>
</div>
</div>
</a>
}
</div>
<!-- Infinite scroll anchor -->
<app-infinite-anchor class="mt-6"
[disabled]="!nextCursor()"
[busy]="busyMore()"
(loadMore)="fetchNextPage()"></app-infinite-anchor>
@if (busyMore()) {
<div class="flex items-center justify-center py-4 text-slate-400">
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
{{ 'loading.more' | t }}
</div>
}
}
</div>

View File

@ -0,0 +1,126 @@
import { ChangeDetectionStrategy, Component, effect, inject, signal, untracked } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { Video } from '../../models/video.model';
import { InstanceService } from '../../services/instance.service';
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
import { formatRelativeFr } from '../../utils/date.util';
import { TranslatePipe } from '../../pipes/translate.pipe';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe]
})
export class HomeComponent {
private apiService = inject(YoutubeApiService);
private instances = inject(InstanceService);
trendingVideos = signal<Video[]>([]);
loading = signal(true);
busyMore = signal(false);
nextCursor = signal<string | null>(null);
notice = signal<string | null>(null);
constructor() {
this.reloadTrending();
// React to provider/region changes to refresh trending automatically
effect(() => {
// Rerun when these signals change
const provider = this.instances.selectedProvider();
const region = this.instances.region();
const ptInstance = this.instances.activePeerTubeInstance();
// Reset notice and trigger reload, but without re-triggering the effect
untracked(() => {
this.notice.set(null);
this.reloadTrending();
});
}, { allowSignalWrites: true });
}
reloadTrending() {
const readiness = this.instances.getProviderReadiness();
if (!readiness.ready) {
this.notice.set(readiness.reason || 'Le provider sélectionné n\'est pas prêt.');
this.trendingVideos.set([]);
this.nextCursor.set(null);
this.loading.set(false);
return;
}
this.loading.set(true);
this.trendingVideos.set([]);
this.nextCursor.set(null);
// Important: reset busy flag so a previous pending state doesn't block new loads
this.busyMore.set(false);
this.fetchNextPage();
}
fetchNextPage() {
if (this.busyMore()) return;
const readiness = this.instances.getProviderReadiness();
if (!readiness.ready) {
if (!this.notice()) this.notice.set(readiness.reason || 'Le provider sélectionné n\'est pas prêt.');
return;
}
this.busyMore.set(true);
this.apiService.getTrendingPage(this.nextCursor()).subscribe(res => {
const merged = [...this.trendingVideos(), ...res.items];
this.trendingVideos.set(merged);
this.nextCursor.set(res.nextCursor || null);
this.busyMore.set(false);
this.loading.set(false);
const provider = this.instances.selectedProvider();
if (merged.length === 0 && !this.notice()) {
const readiness2 = this.instances.getProviderReadiness();
if (!readiness2.ready) {
this.notice.set(readiness2.reason || 'Le provider sélectionné n\'est pas prêt.');
} else if (provider === 'youtube') {
this.notice.set('Aucune vidéo tendance YouTube chargée. Vérifiez que votre YOUTUBE_API_KEY est valide et que les restrictions HTTP referrer incluent http://localhost:4200/*.');
} else if (provider === 'peertube') {
const inst = this.instances.activePeerTubeInstance();
this.notice.set(`PeerTube: les vidéos ne sont pas disponibles depuis l'instance "${inst}" pour le moment. Essayez une autre instance dans l'en-tête.`);
} else if (provider === 'rumble') {
const label = this.instances.selectedProviderLabel();
this.notice.set(`Les vidéos ne sont pas disponibles pour le provider "${label}" pour le moment. Réessayez plus tard ou choisissez un autre provider.`);
}
}
});
}
formatViews(views: number): string {
if (views >= 1_000_000_000) {
return (views / 1_000_000_000).toFixed(1) + 'B';
}
if (views >= 1_000_000) {
return (views / 1_000_000).toFixed(1) + 'M';
}
if (views >= 1_000) {
return (views / 1_000).toFixed(1) + 'K';
}
return views.toString();
}
// Relative date for cards (e.g., "il y a 2 heures")
formatRelative(dateIso?: string): string {
if (!dateIso) return '';
return formatRelativeFr(dateIso);
}
// Build query params for Watch page (provider + optional odysee slug)
watchQueryParams(v: Video): Record<string, any> | null {
const p = this.instances.selectedProvider();
const qp: any = { p };
if (p === 'odysee' && v.url?.startsWith('https://odysee.com/')) {
let slug = v.url.substring('https://odysee.com/'.length);
if (slug.startsWith('/')) slug = slug.slice(1);
qp.slug = slug;
}
return qp;
}
}

View File

@ -0,0 +1,119 @@
<div class="container mx-auto p-4 sm:p-6">
<h1 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">🧭 Guide d'utilisation</h1>
<div class="grid grid-cols-1 gap-6">
<!-- Introduction -->
<section class="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-700">
<h2 class="text-lg font-semibold text-slate-100">👋 Bienvenue sur NewTube</h2>
<p class="text-sm text-slate-400">Cette page explique comment utiliser le site, découvrir des contenus, gérer vos préférences et tirer le meilleur parti des fournisseurs comme PeerTube, YouTube, Twitch, etc. 🎬</p>
</div>
<div class="p-5 space-y-4 text-slate-200">
<p>Utilisez le panneau de navigation à gauche pour accéder rapidement aux thèmes, aux tendances, à vos abonnements et à cette page d'aide. 🧰</p>
<ul class="space-y-1 text-slate-300">
<li>🏠 <span class="text-slate-200 font-medium">Accueil</span> — contenus populaires selon le thème sélectionné.</li>
<li>🎞️ <span class="text-slate-200 font-medium">Shorts</span> — vidéos courtes dans un lecteur optimisé.</li>
<li>📚 <span class="text-slate-200 font-medium">Bibliothèque</span> — playlists, vidéos aimées et abonnements.</li>
<li>⚙️ <span class="text-slate-200 font-medium">Compte</span> — préférences, historique et gestion des sessions.</li>
</ul>
<div class="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="rounded-lg border border-slate-700 bg-slate-900/40 p-4">
<div class="text-xl">🔍</div>
<div class="mt-1 text-sm text-slate-300">Recherchez et explorez par thèmes</div>
</div>
<div class="rounded-lg border border-slate-700 bg-slate-900/40 p-4">
<div class="text-xl">🧩</div>
<div class="mt-1 text-sm text-slate-300">Choisissez un fournisseur (YouTube, PeerTube...)</div>
</div>
<div class="rounded-lg border border-slate-700 bg-slate-900/40 p-4">
<div class="text-xl">🎛️</div>
<div class="mt-1 text-sm text-slate-300">Personnalisez vos préférences</div>
</div>
</div>
</div>
</section>
<!-- Recherche -->
<section class="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-700">
<h2 class="text-lg font-semibold text-slate-100">🔎 Rechercher des vidéos</h2>
<p class="text-sm text-slate-400">Cherchez sur plusieurs plateformes depuis une seule barre de recherche.</p>
</div>
<div class="p-5 space-y-3 text-slate-200">
<ul class="space-y-1 text-slate-300">
<li>⌨️ Tapez votre requête et appuyez sur Entrée.</li>
<li>🏷️ Filtrez par thèmes via la barre située sous l'en-tête.</li>
<li>▶️ Ouvrez une vidéo pour afficher la page de lecture et les actions.</li>
</ul>
<div class="mt-3 rounded-lg border border-sky-700/60 bg-sky-900/20 px-4 py-3 text-sky-200">
💡 Astuce&nbsp;: combinez un thème et un mot-clé (ex.&nbsp;: «&nbsp;Tech IA&nbsp;») pour des résultats plus pertinents.
</div>
</div>
</section>
<!-- Thèmes et Fournisseurs -->
<section class="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-700">
<h2 class="text-lg font-semibold text-slate-100">🧩 Thèmes et Fournisseurs</h2>
<p class="text-sm text-slate-400">Parcourez par centres d'intérêt et choisissez un fournisseur de contenus.</p>
</div>
<div class="p-5 space-y-3 text-slate-200">
<ul class="space-y-1 text-slate-300">
<li>🧭 <span class="text-slate-200 font-medium">Explorer</span> — liste de thèmes (Sport, Tech, Jeux, etc.).</li>
<li>🎥 <span class="text-slate-200 font-medium">Fournisseurs</span> — YouTube, Dailymotion, Twitch, Rumble, Odysee et PeerTube.</li>
<li>🟠 <span class="text-slate-200 font-medium">PeerTube</span> — gérez et basculez entre vos instances dans les Préférences.</li>
</ul>
</div>
</section>
<!-- PeerTube: Ressource utile -->
<section class="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-700 flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-slate-100">🟠 Trouver des instances PeerTube</h2>
<p class="text-sm text-slate-400">Découvrez de nouvelles instances PeerTube publiques et actives.</p>
</div>
<a class="px-4 py-2 rounded bg-emerald-600 hover:bg-emerald-500 text-white" href="https://joinpeertube.org/" target="_blank" rel="noopener noreferrer">Ouvrir joinpeertube.org ↗️</a>
</div>
<div class="p-5 text-slate-200">
<p>Une instance PeerTube est un serveur hébergeant des vidéos. Vous pouvez en ajouter plusieurs dans <span class="font-medium">Compte → Préférences → PeerTube</span> puis choisir celle active. Pour en trouver, consultez le <a class="text-sky-400 hover:text-sky-300 underline" href="https://joinpeertube.org/" target="_blank" rel="noopener noreferrer">répertoire des instances</a>.</p>
</div>
</section>
<!-- Confidentialité et Paramètres -->
<section class="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-700">
<h2 class="text-lg font-semibold text-slate-100">🔐 Confidentialité et paramètres</h2>
<p class="text-sm text-slate-400">Personnalisez votre expérience dans la page Préférences.</p>
</div>
<div class="p-5 space-y-2 text-slate-200">
<ul class="space-y-1 text-slate-300">
<li>🌐 Choisissez votre langue, thème visuel et qualité vidéo par défaut.</li>
<li>🎯 Définissez un fournisseur par défaut et gérez vos instances PeerTube.</li>
<li>🕑 Consultez et effacez l'historique de visionnage dans <span class="font-medium">Compte → Historique</span>.</li>
</ul>
<div class="mt-3 rounded-lg border border-amber-600/60 bg-amber-900/20 px-4 py-3 text-amber-200">
⚠️ Conseil&nbsp;: si une instance PeerTube est lente, essayez d'en définir une autre comme active.
</div>
</div>
</section>
<!-- Aide supplémentaire -->
<section class="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-700">
<h2 class="text-lg font-semibold text-slate-100">🆘 Besoin d'aide ?</h2>
<p class="text-sm text-slate-400">Signalez un problème ou consultez le code source.</p>
</div>
<div class="p-5 text-slate-200 space-y-2">
<p>Le dépôt du projet est disponible sur notre instance Gitea&nbsp;:</p>
<p>
<a class="inline-flex items-center gap-2 px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-white"
href="https://git.dracodev.net/Projets/NewTube" target="_blank" rel="noopener noreferrer">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2zm1 14.59V20h-2v-3.41a4.994 4.994 0 0 1-4-4.9V7h2v4.69A3 3 0 0 0 12 14a3 3 0 0 0 3-2.31V7h2v4.69a4.994 4.994 0 0 1-4 4.9z"/></svg>
Ouvrir Gitea ↗️
</a>
</p>
</div>
</section>
</div>
</div>

View File

@ -0,0 +1,11 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-utilisation',
standalone: true,
imports: [CommonModule],
templateUrl: './utilisation.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UtilisationComponent {}

View File

@ -0,0 +1,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">Vidéos que vous aimez</h2>
<div class="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
<p class="text-slate-300">Ici apparaîtront vos vidéos aimées (tous providers). Fonctionnalité à venir.</p>
</div>
</div>

View File

@ -0,0 +1,11 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-library-liked',
standalone: true,
imports: [CommonModule],
templateUrl: './liked.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LikedComponent { }

View File

@ -0,0 +1,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">Listes de lecture</h2>
<div class="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
<p class="text-slate-300">Bientôt disponible: vos listes de lecture centralisées, tous providers confondus.</p>
</div>
</div>

View File

@ -0,0 +1,11 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-library-playlists',
standalone: true,
imports: [CommonModule],
templateUrl: './playlists.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlaylistsComponent { }

View File

@ -0,0 +1,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">Abonnements</h2>
<div class="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
<p class="text-slate-300">Section pour suivre vos abonnements (à implémenter). Vous verrez ici les dernières vidéos des chaînes suivies sur chaque provider.</p>
</div>
</div>

View File

@ -0,0 +1,11 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-library-subscriptions',
standalone: true,
imports: [CommonModule],
templateUrl: './subscriptions.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SubscriptionsComponent { }

View File

@ -0,0 +1,151 @@
<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>
@if (notice()) {
<div class="mb-4 bg-amber-900/40 border border-amber-600 text-amber-200 p-3 rounded">
{{ notice() }}
</div>
}
@if (!hasQuery()) {
<p class="text-slate-400">{{ 'search.hint' | t }}</p>
}
@if (hasQuery() && availableTags().length > 0) {
<div class="mb-4 flex items-center gap-2 overflow-x-auto pb-1">
@for (tag of availableTags(); track tag.key) {
<button
class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
[ngClass]="{
'bg-slate-200 text-slate-900 border-slate-200': filterTag() === tag.key,
'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700': filterTag() !== tag.key
}"
(click)="setFilterTag(tag.key)">
{{ tag.label }}
</button>
}
</div>
}
@if (loading()) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
@for (item of [1,2,3,4,5,6,7,8]; track item) {
<div class="animate-pulse bg-slate-800 rounded-lg overflow-hidden">
<div class="w-full h-48 bg-slate-700"></div>
<div class="p-4 space-y-3">
<div class="h-4 bg-slate-700 rounded w-3/4"></div>
<div class="h-4 bg-slate-700 rounded w-1/2"></div>
</div>
</div>
}
</div>
} @else if (selectedProviderForView() === 'twitch') {
<!-- Twitch: two sections -->
<div class="space-y-10">
<!-- Live Channels -->
<section *ngIf="filterTag() === 'twitch_all' || filterTag() === 'twitch_live'">
<h3 class="text-xl font-semibold text-slate-200 mb-3">Chaînes en direct</h3>
@if (twitchChannels().length > 0) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-8">
@for (video of twitchChannels(); track video.videoId) {
<a [routerLink]="['/watch', video.videoId]" [queryParams]="watchQueryParams(video)" [state]="{ video }" class="group flex flex-col bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1">
<div class="relative">
<img [src]="video.thumbnail" [alt]="video.title" class="w-full h-48 object-cover transition-transform duration-300 group-hover:scale-105">
<div class="absolute top-2 left-2 bg-black/75 text-white text-xs px-2 py-1 rounded">
{{ formatViews(video.views) }} en direct
</div>
</div>
<div class="p-4 flex-grow flex flex-col">
<h4 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 line-clamp-2">{{ video.title }}</h4>
<div class="mt-2 flex items-center space-x-3 text-sm text-slate-400">
<img [src]="video.uploaderAvatar" [alt]="video.uploaderName" class="w-8 h-8 rounded-full">
<span>{{ video.uploaderName }}</span>
</div>
</div>
</a>
}
</div>
@if (twitchCursorChannels()) {
<div class="mt-4 flex justify-center">
<button class="px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 disabled:opacity-50" (click)="loadMoreTwitchChannels()" [disabled]="twitchBusyChannels()">Afficher plus</button>
</div>
}
} @else {
<p class="text-slate-400">Aucune chaîne en direct trouvée.</p>
}
</section>
<!-- Past Videos (VODs) -->
<section *ngIf="filterTag() === 'twitch_all' || filterTag() === 'twitch_vod'">
<h3 class="text-xl font-semibold text-slate-200 mb-3">Vidéos (VOD)</h3>
@if (twitchVods().length > 0) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-8">
@for (video of twitchVods(); track video.videoId) {
<a [routerLink]="['/watch', video.videoId]" [queryParams]="watchQueryParams(video)" [state]="{ video }" class="group flex flex-col bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1">
<div class="relative">
<img [src]="video.thumbnail" [alt]="video.title" class="w-full h-48 object-cover transition-transform duration-300 group-hover:scale-105">
<div class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded">
{{ video.duration / 60 | number:'1.0-0' }}:{{ (video.duration % 60) | number:'2.0-0' }}
</div>
</div>
<div class="p-4 flex-grow flex flex-col">
<h4 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 line-clamp-2">{{ video.title }}</h4>
<div class="mt-2 flex items-center space-x-3 text-sm text-slate-400">
<img [src]="video.uploaderAvatar" [alt]="video.uploaderName" class="w-8 h-8 rounded-full">
<span>{{ video.uploaderName }}</span>
</div>
<div class="mt-auto pt-2 text-sm text-slate-400">
<span>{{ formatViews(video.views) }} visionnements</span> &bull; <span>{{ formatRelative(video.uploadedDate) }}</span>
</div>
</div>
</a>
}
</div>
@if (twitchCursorVods()) {
<div class="mt-4 flex justify-center">
<button class="px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 disabled:opacity-50" (click)="loadMoreTwitchVods()" [disabled]="twitchBusyVods()">Afficher plus</button>
</div>
}
} @else {
<p class="text-slate-400">Aucune vidéo VOD trouvée.</p>
}
</section>
</div>
} @else if (results().length > 0) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-8">
@for (video of filteredResults(); track video.videoId) {
<a [routerLink]="['/watch', video.videoId]" [queryParams]="watchQueryParams(video)" [state]="{ video }" class="group flex flex-col bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1">
<div class="relative">
<img [src]="video.thumbnail" [alt]="video.title" class="w-full h-48 object-cover transition-transform duration-300 group-hover:scale-105">
<div class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded">
{{ video.duration / 60 | number:'1.0-0' }}:{{ (video.duration % 60) | number:'2.0-0' }}
</div>
</div>
<div class="p-4 flex-grow flex flex-col">
<h3 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 line-clamp-2">{{ video.title }}</h3>
<div class="mt-2 flex items-center space-x-3 text-sm text-slate-400">
<img [src]="video.uploaderAvatar" [alt]="video.uploaderName" class="w-8 h-8 rounded-full">
<span>{{ video.uploaderName }}</span>
</div>
<div class="mt-auto pt-2 text-sm text-slate-400">
<span>{{ formatViews(video.views) }} visionnements</span> &bull; <span>{{ formatRelative(video.uploadedDate) }}</span>
</div>
</div>
</a>
}
</div>
<!-- Infinite scroll anchor -->
<app-infinite-anchor class="mt-6"
[disabled]="!nextCursor()"
[busy]="busyMore()"
(loadMore)="fetchNextPage()"></app-infinite-anchor>
@if (busyMore()) {
<div class="flex items-center justify-center py-4 text-slate-400">
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
{{ 'loading.more' | t }}
</div>
}
} @else if (hasQuery()) {
<p class="text-slate-400">{{ 'search.noResults' | t }}</p>
}
</div>

View File

@ -0,0 +1,359 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, untracked } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, 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';
import { formatRelativeFr } from '../../utils/date.util';
import { InstanceService, Provider } from '../../services/instance.service';
import { HistoryService } from '../../services/history.service';
import { Title } from '@angular/platform-browser';
import { TranslatePipe } from '../../pipes/translate.pipe';
@Component({
selector: 'app-search',
standalone: true,
templateUrl: './search.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe]
})
export class SearchComponent {
private route = inject(ActivatedRoute);
private api = inject(YoutubeApiService);
private instances = inject(InstanceService);
private history = inject(HistoryService);
private title = inject(Title);
q = signal<string>('');
loading = signal<boolean>(false);
results = signal<Video[]>([]);
busyMore = signal<boolean>(false);
nextCursor = signal<string | null>(null);
// Twitch-specific dual lists
twitchChannels = signal<Video[]>([]);
twitchVods = signal<Video[]>([]);
twitchCursorChannels = signal<string | null>(null);
twitchCursorVods = signal<string | null>(null);
twitchBusyChannels = signal<boolean>(false);
twitchBusyVods = signal<boolean>(false);
notice = signal<string | null>(null);
providerParam = signal<Provider | null>(null);
themeParam = signal<string | null>(null);
hasQuery = computed(() => this.q().length > 0);
providerLabel = computed(() => {
const param = this.providerParam();
if (param) {
const p = this.instances.providers().find(p => p.id === param);
return p?.label || param;
}
return this.instances.selectedProviderLabel();
});
pageHeading = computed(() => `Search - ${this.providerLabel()}${this.q() ? ' - ' + this.q() : ''}`);
// Public computed used by the template to avoid referencing private `instances`
selectedProviderForView = computed(() => this.providerParam() || this.instances.selectedProvider());
// Active tag filter. For non-Twitch providers: 'all' | 'short' | 'medium' | 'long' | 'recent'.
// For Twitch provider: 'twitch_all' | 'twitch_live' | 'twitch_vod'.
filterTag = signal<string>('all');
// Helper computed: filtered list based on active tag (non-Twitch only)
filteredResults = computed(() => {
const tag = this.filterTag();
const provider = this.selectedProviderForView();
const list = this.results();
if (provider === 'twitch') return list; // Not used for Twitch (separate sections)
if (tag === 'all') return list;
const now = Date.now();
const oneDay = 24 * 60 * 60 * 1000;
const sevenDays = 7 * oneDay;
const thirtyDays = 30 * oneDay;
const oneYear = 365 * oneDay;
// Duration filters
if (tag === 'short') return list.filter(v => {
const d = Number(v.duration || 0);
return d > 0 && d < 4 * 60;
});
if (tag === 'medium') return list.filter(v => {
const d = Number(v.duration || 0);
return d >= 4 * 60 && d < 20 * 60;
});
if (tag === 'long') return list.filter(v => {
const d = Number(v.duration || 0);
return d >= 20 * 60;
});
// Date filters
if (tag === 'today') return list.filter(v => {
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
return uploadTime > 0 && (now - uploadTime) <= oneDay;
});
if (tag === 'this_week') return list.filter(v => {
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
return uploadTime > 0 && (now - uploadTime) <= sevenDays;
});
if (tag === 'this_month') return list.filter(v => {
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
return uploadTime > 0 && (now - uploadTime) <= thirtyDays;
});
if (tag === 'this_year') return list.filter(v => {
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
return uploadTime > 0 && (now - uploadTime) <= oneYear;
});
return list;
});
// Available tags computed from current results (only show tags that can apply)
availableTags = computed(() => {
const provider = this.selectedProviderForView();
const tags: { key: string; label: string; show: boolean }[] = [];
const now = Date.now();
const list = this.results();
if (provider === 'twitch') {
const hasLive = this.twitchChannels().length > 0;
const hasVods = this.twitchVods().length > 0;
// Show the group if at least one of the sections is available
if (hasLive || hasVods) {
tags.push({ key: 'twitch_all', label: 'Tout', show: hasLive && hasVods });
tags.push({ key: 'twitch_live', label: 'En direct', show: hasLive });
tags.push({ key: 'twitch_vod', label: 'VOD', show: hasVods });
}
return tags.filter(t => t.show);
}
// Non-Twitch providers: duration + date filters
const oneDay = 24 * 60 * 60 * 1000;
const sevenDays = 7 * oneDay;
const thirtyDays = 30 * oneDay;
const oneYear = 365 * oneDay;
let hasShort = false, hasMedium = false, hasLong = false,
hasToday = false, hasThisWeek = false, hasThisMonth = false, hasThisYear = false;
for (const v of list) {
const d = Number(v.duration || 0);
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
const timeDiff = now - uploadTime;
// Duration filters
if (d > 0 && d < 4 * 60) hasShort = true;
else if (d >= 4 * 60 && d < 20 * 60) hasMedium = true;
else if (d >= 20 * 60) hasLong = true;
// Date filters
if (uploadTime > 0) {
if (timeDiff <= oneDay) hasToday = true;
if (timeDiff <= sevenDays) hasThisWeek = true;
if (timeDiff <= thirtyDays) hasThisMonth = true;
if (timeDiff <= oneYear) hasThisYear = true;
}
}
// Add duration filters
tags.push({ key: 'all', label: 'Tout', show: list.length > 0 });
tags.push({ key: 'short', label: 'Moins de 4 min', show: hasShort });
tags.push({ key: 'medium', label: 'De 4 à 20 min', show: hasMedium });
tags.push({ key: 'long', label: '20 min et plus', show: hasLong });
// Add date filters
tags.push({ key: 'today', label: 'Aujourd\'hui', show: hasToday });
tags.push({ key: 'this_week', label: 'Cette semaine', show: hasThisWeek });
tags.push({ key: 'this_month', label: 'Ce mois-ci', show: hasThisMonth });
tags.push({ key: 'this_year', label: 'Cette année', show: hasThisYear });
return tags.filter(t => t.show);
});
constructor() {
// Update document title when provider or query changes
effect(() => {
this.title.setTitle(this.pageHeading());
});
// Listen to query param changes (so subsequent searches update)
this.route.queryParamMap.subscribe((pm) => {
const q = (pm.get('q') || '').trim();
this.q.set(q);
const prov = (pm.get('provider') as Provider) || null;
const theme = pm.get('theme');
this.providerParam.set(prov);
this.themeParam.set(theme);
if (q) {
this.notice.set(null);
// Reset active tag on new query
const provider = (prov || this.instances.selectedProvider());
this.filterTag.set(provider === 'twitch' ? 'twitch_all' : 'all');
this.reloadSearch();
} else {
this.results.set([]);
this.nextCursor.set(null);
this.loading.set(false);
}
});
// React to provider/region/PeerTube instance changes to refresh search automatically
effect(() => {
const provider = this.instances.selectedProvider();
const region = this.instances.region();
const ptInstance = this.instances.activePeerTubeInstance();
untracked(() => {
// If provider is explicitly specified in query, do not override it with global changes
if (this.providerParam()) return;
if (this.q()) {
this.notice.set(null);
this.reloadSearch();
}
});
}, { allowSignalWrites: true });
}
reloadSearch() {
const readiness = this.instances.getProviderReadiness(this.providerParam() || undefined as any);
if (!readiness.ready) {
this.notice.set(readiness.reason || 'Le provider sélectionné n\'est pas prêt.');
this.results.set([]);
this.nextCursor.set(null);
this.twitchChannels.set([]);
this.twitchVods.set([]);
this.twitchCursorChannels.set(null);
this.twitchCursorVods.set(null);
this.loading.set(false);
return;
}
this.loading.set(true);
this.results.set([]);
this.nextCursor.set(null);
this.twitchChannels.set([]);
this.twitchVods.set([]);
this.twitchCursorChannels.set(null);
this.twitchCursorVods.set(null);
const provider = this.providerParam() || this.instances.selectedProvider();
// Record search term with provider once per reload
try {
this.history.recordSearch(this.q(), { provider }).subscribe({
next: () => {},
error: (err) => console.error('Error recording search:', err)
});
} catch (err) {
console.error('Error in recordSearch:', err);
}
// Ensure default tag matches provider
this.filterTag.set(provider === 'twitch' ? 'twitch_all' : 'all');
if (provider === 'twitch') {
// Load both sections in parallel
this.twitchBusyChannels.set(true);
this.twitchBusyVods.set(true);
this.api.searchTwitchChannelsPage(this.q(), null).subscribe(res => {
this.twitchChannels.set(res.items);
this.twitchCursorChannels.set(res.nextCursor || null);
this.twitchBusyChannels.set(false);
this.loading.set(false);
});
this.api.searchTwitchVodsPage(this.q(), null).subscribe(res => {
this.twitchVods.set(res.items);
this.twitchCursorVods.set(res.nextCursor || null);
this.twitchBusyVods.set(false);
this.loading.set(false);
});
} else {
this.fetchNextPage();
}
}
fetchNextPage() {
if (this.busyMore() || !this.q()) return;
const readiness = this.instances.getProviderReadiness(this.providerParam() || undefined as any);
if (!readiness.ready) {
if (!this.notice()) this.notice.set(readiness.reason || 'Le provider sélectionné n\'est pas prêt.');
return;
}
this.busyMore.set(true);
const providerOverride = this.providerParam();
this.api.searchVideosPage(this.q(), this.nextCursor(), providerOverride as any).subscribe(res => {
const merged = [...this.results(), ...res.items];
this.results.set(merged);
this.nextCursor.set(res.nextCursor || null);
this.busyMore.set(false);
this.loading.set(false);
// Provider/instance availability notices (avoid overriding legitimate no-results unless clear)
if (merged.length === 0 && !this.notice()) {
const readiness2 = this.instances.getProviderReadiness();
if (!readiness2.ready) {
this.notice.set(readiness2.reason || 'Le provider sélectionné n\'est pas prêt.');
} else {
const provider = this.providerParam() || this.instances.selectedProvider();
if (provider === 'peertube') {
const inst = this.instances.activePeerTubeInstance();
this.notice.set(`PeerTube: les vidéos ne sont pas disponibles depuis l'instance "${inst}" pour le moment. Essayez une autre instance dans l'en-tête.`);
} else if (provider === 'rumble') {
const label = this.instances.selectedProviderLabel();
this.notice.set(`Les vidéos ne sont pas disponibles pour le provider "${label}" pour le moment. Réessayez plus tard ou choisissez un autre provider.`);
}
}
}
});
}
loadMoreTwitchChannels() {
if (this.twitchBusyChannels() || !this.twitchCursorChannels()) return;
this.twitchBusyChannels.set(true);
this.api.searchTwitchChannelsPage(this.q(), this.twitchCursorChannels()).subscribe(res => {
this.twitchChannels.set([...this.twitchChannels(), ...res.items]);
this.twitchCursorChannels.set(res.nextCursor || null);
this.twitchBusyChannels.set(false);
});
}
loadMoreTwitchVods() {
if (this.twitchBusyVods() || !this.twitchCursorVods()) return;
this.twitchBusyVods.set(true);
this.api.searchTwitchVodsPage(this.q(), this.twitchCursorVods()).subscribe(res => {
this.twitchVods.set([...this.twitchVods(), ...res.items]);
this.twitchCursorVods.set(res.nextCursor || null);
this.twitchBusyVods.set(false);
});
}
formatViews(views: number): string {
if (views >= 1_000_000_000) return (views / 1_000_000_000).toFixed(1) + 'B';
if (views >= 1_000_000) return (views / 1_000_000).toFixed(1) + 'M';
if (views >= 1_000) return (views / 1_000).toFixed(1) + 'K';
return views.toString();
}
formatRelative(dateIso?: string): string {
if (!dateIso) return '';
return formatRelativeFr(dateIso);
}
// Build query params for Watch page (provider + optional odysee slug)
watchQueryParams(v: Video): Record<string, any> | null {
const p = this.providerParam() || this.instances.selectedProvider();
const qp: any = { p };
if (p === 'odysee' && v.url?.startsWith('https://odysee.com/')) {
let slug = v.url.substring('https://odysee.com/'.length);
if (slug.startsWith('/')) slug = slug.slice(1);
qp.slug = slug;
}
if (p === 'twitch' && v.type === 'channel') {
// Extract channel login from uploaderUrl if available
const url = v.uploaderUrl || v.url || '';
try {
const u = new URL(url);
const parts = u.pathname.split('/').filter(Boolean);
if (parts.length > 0) qp.channel = parts[0];
} catch {}
}
return qp;
}
// Update active filter tag from the template
setFilterTag(key: string) {
this.filterTag.set(key);
}
}

View File

@ -0,0 +1,5 @@
<div #sentinel class="w-full h-10 flex items-center justify-center">
<div class="text-slate-400 text-sm" aria-live="polite">
<!-- The parent can show a spinner near the bottom; this anchor is the sentinel only. -->
</div>
</div>

View File

@ -0,0 +1,42 @@
import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, OnDestroy, Output, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-infinite-anchor',
standalone: true,
templateUrl: './infinite-anchor.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule]
})
export class InfiniteAnchorComponent implements AfterViewInit, OnDestroy {
@ViewChild('sentinel', { static: true }) sentinelRef!: ElementRef<HTMLDivElement>;
@Input() disabled = false;
@Input() busy = false;
@Input() rootMargin = '300px 0px 300px 0px';
@Input() threshold: number | number[] = 0.01;
@Output() loadMore = new EventEmitter<void>();
private observer?: IntersectionObserver;
ngAfterViewInit(): void {
this.observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting && !this.disabled && !this.busy) {
this.loadMore.emit();
}
}
}, {
root: null,
rootMargin: this.rootMargin,
threshold: this.threshold,
});
this.observer.observe(this.sentinelRef.nativeElement);
}
ngOnDestroy(): void {
this.observer?.disconnect();
}
}

View File

@ -0,0 +1,52 @@
<div class="h-[calc(100vh-4rem)] w-full flex items-center justify-center p-2 sm:p-4"
(wheel)="onWheel($event)" (touchstart)="onTouchStart($event)" (touchend)="onTouchEnd($event)">
<!-- Center column -->
<div class="relative">
<!-- Video frame (9:16) -->
<div class="rounded-xl overflow-hidden shadow-2xl ring-1 ring-slate-700/50 bg-black/90"
style="height: calc(100vh - 6rem); max-height: calc(100vh - 6rem); aspect-ratio: 9 / 16; width: auto; max-width: 92vw;">
@if (embedUrl(); as url) {
<iframe [src]="url" class="w-full h-full" frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen></iframe>
} @else if (loading()) {
<div class="w-full h-full animate-pulse bg-slate-800"></div>
} @else if (error()) {
<div class="w-full h-full flex items-center justify-center text-slate-300">
{{ error() }}
</div>
}
<!-- Bottom overlay: meta -->
@if (current(); as v) {
<div class="absolute left-0 right-0 bottom-0 p-3 sm:p-4 bg-gradient-to-t from-black/60 to-transparent text-white">
<div class="flex items-center gap-3 text-sm">
<img [src]="v.uploaderAvatar" [alt]="v.uploaderName" class="h-8 w-8 rounded-full object-cover">
<div class="flex-1">
<div class="font-semibold line-clamp-1">{{ v.title }}</div>
<div class="opacity-80 line-clamp-1">@{{ v.uploaderName }}</div>
</div>
</div>
</div>
}
</div>
<!-- Navigation buttons -->
<button (click)="prev()" [disabled]="!canPrev()"
class="absolute -right-14 top-6 hidden md:flex items-center justify-center h-10 w-10 rounded-full bg-slate-800/70 hover:bg-slate-700 text-white ring-1 ring-slate-700/60 disabled:opacity-40"
aria-label="Previous short">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M7 14l5-5 5 5H7z"/></svg>
</button>
<button (click)="next()" [disabled]="!canNext()"
class="absolute -right-14 top-20 hidden md:flex items-center justify-center h-10 w-10 rounded-full bg-slate-800/70 hover:bg-slate-700 text-white ring-1 ring-slate-700/60 disabled:opacity-40"
aria-label="Next short">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M7 10l5 5 5-5H7z"/></svg>
</button>
<!-- Mobile next/prev (bottom) -->
<div class="flex md:hidden items-center justify-between mt-3">
<button (click)="prev()" [disabled]="!canPrev()" class="px-3 py-2 rounded bg-slate-800/70 text-white disabled:opacity-40">Prev</button>
<button (click)="next()" [disabled]="!canNext()" class="px-3 py-2 rounded bg-slate-800/70 text-white disabled:opacity-40">Next</button>
</div>
</div>
</div>

View File

@ -0,0 +1,267 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal, HostListener } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { firstValueFrom } from 'rxjs';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { InstanceService } from '../../services/instance.service';
import { Video } from '../../models/video.model';
@Component({
selector: 'app-watch-short',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
templateUrl: './watch-short.component.html'
})
export class WatchShortComponent {
private api = inject(YoutubeApiService);
private instances = inject(InstanceService);
private sanitizer = inject(DomSanitizer);
loading = signal(true);
error = signal<string | null>(null);
items = signal<Video[]>([]);
index = signal(0);
nextCursor = signal<string | null>(null);
busyMore = signal(false);
// scroll/swipe helpers
private wheelAccum = 0;
private lastSwipeY: number | null = null;
private lastScrollTs = 0;
readonly provider = computed(() => this.instances.selectedProvider());
readonly current = computed<Video | null>(() => {
const list = this.items();
const i = this.index();
return (i >= 0 && i < list.length) ? list[i] : null;
});
readonly canPrev = computed(() => this.index() > 0);
readonly canNext = computed(() => this.index() < this.items().length - 1);
readonly embedUrl = computed<SafeResourceUrl | null>(() => {
const v = this.current();
if (!v) return null;
const p = this.provider();
try {
const host = (location && location.hostname) ? location.hostname : 'localhost';
if (p === 'youtube' && v.videoId) {
const qs = 'autoplay=1&mute=1&rel=0&modestbranding=1&playsinline=1&controls=1';
const u = `https://www.youtube.com/embed/${encodeURIComponent(v.videoId)}?${qs}`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'dailymotion' && v.videoId) {
const u = `https://www.dailymotion.com/embed/video/${encodeURIComponent(v.videoId)}?autoplay=1&mute=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'twitch' && v.videoId) {
const parentsSet = new Set<string>([host, 'localhost', '127.0.0.1']);
const parentParams = Array.from(parentsSet).map(h => `parent=${encodeURIComponent(h)}`).join('&');
const base = 'https://player.twitch.tv/';
const qs = `?channel=${encodeURIComponent(v.videoId)}&${parentParams}&autoplay=false`;
const u = base + qs;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'peertube' && v.videoId) {
const inst = this.instances.activePeerTubeInstance();
const u = `https://${inst}/videos/embed/${encodeURIComponent(v.videoId)}?autoplay=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'odysee') {
// Try to derive slug from URL
let slug: string | null = null;
try {
const url = new URL(v.url || '');
slug = url.pathname.startsWith('/') ? url.pathname.slice(1) : url.pathname;
} catch {}
if (slug || v.videoId) {
const target = slug || v.videoId;
const u = `https://odysee.com/$/embed/${target}?autoplay=1&muted=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
}
if (p === 'rumble' && v.videoId) {
const u = `https://rumble.com/embed/${encodeURIComponent(v.videoId)}/?autoplay=2&muted=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
} catch {}
return null;
});
// Called when a query returns no usable items; falls back to Trending
private afterNoResults(): void {
const p = this.provider();
const hint = p === 'youtube'
? 'Aucun Short trouvé. Assurez-vous de définir YOUTUBE_API_KEY dans assets/config.local.js (window.YOUTUBE_API_KEY = "...") ou changez de fournisseur.'
: 'Aucun Short trouvé pour ce fournisseur.';
this.loadFallbackTrending(hint);
}
// Filter YouTube results to real Shorts using the Videos API to get durations.
private async filterYouTubeShorts(list: Video[]): Promise<Video[]> {
const ids = Array.from(new Set(list.map(v => v.videoId))).slice(0, 50);
try {
const durations = await firstValueFrom(this.api.getYouTubeDurations(ids));
const maxShort = 70; // seconds (include some margin)
return list.filter(v => {
const d = durations?.[v.videoId] ?? 0;
return d > 0 && d <= maxShort;
});
} catch {
return [];
}
}
constructor() {
// Basic feed: search for "shorts"
this.loadFeed();
}
loadFeed(): void {
this.loading.set(true);
this.error.set(null);
const ready = this.instances.getProviderReadiness(this.provider());
if (!ready.ready) {
this.loading.set(false);
this.error.set(ready.reason || 'Provider not ready.');
return;
}
this.api.searchVideosPage('shorts OR #shorts').subscribe({
next: (res) => {
const raw = (res.items || []).filter(v => !!v.videoId);
this.nextCursor.set(res.nextCursor || null);
const p = this.provider();
if (p === 'youtube' && raw.length) {
this.filterYouTubeShorts(raw).then(list => {
this.items.set(list);
this.index.set(0);
if (list.length === 0) this.afterNoResults();
else this.error.set(null);
this.loading.set(false);
});
return;
}
// Non-YouTube: take raw as-is
const list = raw;
this.items.set(list);
this.index.set(0);
if (list.length === 0) {
const p = this.provider();
const hint = p === 'youtube'
? 'Aucun Short trouvé. Assurez-vous de définir YOUTUBE_API_KEY dans assets/config.local.js (window.YOUTUBE_API_KEY = "...") ou changez de fournisseur.'
: 'Aucun Short trouvé pour ce fournisseur.';
// Essayez une retombée vers le fil des tendances
this.loadFallbackTrending(hint);
return;
} else {
this.error.set(null);
}
this.loading.set(false);
},
error: () => {
this.error.set('Failed to load Shorts.');
this.loading.set(false);
}
});
}
private loadFallbackTrending(hint?: string): void {
this.api.getTrendingPage().subscribe({
next: (res) => {
const list = (res.items || []).filter(v => !!v.videoId);
this.items.set(list);
this.index.set(0);
this.nextCursor.set(res.nextCursor || null);
if (list.length === 0) this.error.set(hint || 'Aucun contenu disponible.');
else this.error.set(null);
this.loading.set(false);
},
error: () => {
this.error.set(hint || 'Impossible de charger du contenu.');
this.loading.set(false);
}
});
}
private fetchNextPage(autoAdvance = false) {
if (this.busyMore() || !this.nextCursor()) return;
this.busyMore.set(true);
const cursor = this.nextCursor();
this.api.searchVideosPage('shorts OR #shorts', cursor || undefined).subscribe({
next: (res) => {
const raw = (res.items || []).filter(v => !!v.videoId);
const p = this.provider();
const apply = (more: Video[]) => {
const merged = this.items().concat(more);
this.items.set(merged);
this.nextCursor.set(res.nextCursor || null);
if (autoAdvance && more.length > 0) {
this.index.update(i => Math.min(i + 1, merged.length - 1));
}
this.busyMore.set(false);
};
if (p === 'youtube' && raw.length) {
this.filterYouTubeShorts(raw).then(apply).catch(() => { apply([]); });
} else {
apply(raw);
}
},
error: () => {
this.busyMore.set(false);
}
});
}
next(): void {
if (this.canNext()) {
this.index.update(i => i + 1);
} else if (this.nextCursor()) {
this.fetchNextPage(true);
}
}
prev(): void { if (this.canPrev()) this.index.update(i => i - 1); }
@HostListener('document:keydown', ['$event'])
onKey(ev: KeyboardEvent) {
if (this.items().length === 0) return;
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.next(); }
if (ev.key === 'ArrowUp') { ev.preventDefault(); this.prev(); }
}
// Also listen on window to catch events when the cursor is over the iframe
@HostListener('window:wheel', ['$event'])
onWindowWheel(ev: WheelEvent) { this.onWheel(ev); }
@HostListener('window:touchstart', ['$event'])
onWindowTouchStart(ev: TouchEvent) { this.onTouchStart(ev); }
@HostListener('window:touchend', ['$event'])
onWindowTouchEnd(ev: TouchEvent) { this.onTouchEnd(ev); }
// Handle mouse wheel on container (bound in template to allow preventDefault)
onWheel(ev: WheelEvent) {
if (this.items().length === 0) return;
try { ev.preventDefault(); ev.stopPropagation(); } catch {}
const now = Date.now();
if (now - this.lastScrollTs < 250) return; // cooldown to avoid rapid fire
this.wheelAccum += ev.deltaY;
const threshold = 40; // more sensitive for trackpads
if (this.wheelAccum > threshold) { this.wheelAccum = 0; this.lastScrollTs = now; this.next(); }
else if (this.wheelAccum < -threshold) { this.wheelAccum = 0; this.lastScrollTs = now; this.prev(); }
}
onTouchStart(ev: TouchEvent) {
if (this.items().length === 0) return;
this.lastSwipeY = (ev.changedTouches?.[0]?.clientY ?? null);
}
onTouchEnd(ev: TouchEvent) {
if (this.items().length === 0) return;
const y = ev.changedTouches?.[0]?.clientY;
if (this.lastSwipeY == null || y == null) return;
const dy = y - this.lastSwipeY;
const threshold = 50;
if (dy < -threshold) this.next();
else if (dy > threshold) this.prev();
this.lastSwipeY = null;
}
}

View File

@ -0,0 +1,171 @@
<nav class="h-full w-full bg-slate-900 md:bg-transparent text-slate-200 p-3 md:p-0 overflow-y-auto">
<div class="space-y-6">
<!-- Brand (small only) -->
<div class="md:hidden flex items-center gap-2 px-3 py-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-red-500" viewBox="0 0 24 24" fill="currentColor"><path d="M10,15.6l5.3-3.3L10,9V15.6z M21.8,8.1c-0.2-0.8-0.9-1.4-1.7-1.7C18.2,6,12,6,12,6s-6.2,0-8.1,0.5 c-0.8,0.2-1.4,0.9-1.7,1.7C2,9.9,2,12,2,12s0,2.1,0.5,3.9c0.2,0.8,0.9,1.4,1.7,1.7C6.2,18,12,18,12,18s6.2,0,8.1-0.5 c0.8-0.2,1.4-0.9,1.7-1.7c0.5-1.8,0.5-3.9,0.5-3.9S22.2,9.9,21.8,8.1z"/></svg>
<span class="text-xl font-bold">NewTube</span>
</div>
<!-- Main -->
<div>
<ul class="space-y-1">
<li>
<a routerLink="/t/trending" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.home' | t) : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M3 10.5L12 3l9 7.5V21a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1v-10.5z"/></svg>
<span [class.hidden]="collapsed">{{ 'nav.home' | t }}</span>
</a>
</li>
<li>
<a routerLink="/shorts" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.shorts' | t) : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M10 2l8 4v9l-8 4-8-4V6l8-4zm0 2.2L4 6.8v6.4l6 2.6 6-2.6V6.8l-6-2.6z"/></svg>
<span [class.hidden]="collapsed">{{ 'nav.shorts' | t }}</span>
</a>
</li>
<li>
<a routerLink="/library/subscriptions" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.subscriptions' | t) : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zm0 2v12h16V6H4zm3 3h10v2H7V9zm0 4h7v2H7v-2z"/></svg>
<span [class.hidden]="collapsed">{{ 'nav.subscriptions' | t }}</span>
</a>
</li>
</ul>
</div>
<!-- Vous -->
<div>
<div class="px-3 py-2 text-xs uppercase tracking-wide text-slate-400" [class.hidden]="collapsed">{{ 'nav.you' | t }}</div>
<ul class="space-y-1">
<li>
<a routerLink="/account/history" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.history' | t) : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M12 8a1 1 0 0 1 1 1v3.38l2.32 1.34a1 1 0 1 1-1 1.74l-2.82-1.63A1 1 0 0 1 11 13V9a1 1 0 0 1 1-1zm0-6a10 10 0 1 0 10 10 10.011 10.011 0 0 0-10-10z"/></svg>
<span [class.hidden]="collapsed">{{ 'nav.history' | t }}</span>
</a>
</li>
<li>
<a routerLink="/library/playlists" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.playlists' | t) : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M4 6h12v2H4V6zm0 4h12v2H4v-2zm0 4h8v2H4v-2zm14-6h2v8h-2v-8z"/></svg>
<span [class.hidden]="collapsed">{{ 'nav.playlists' | t }}</span>
</a>
</li>
<li>
<a routerLink="/library/liked" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.liked' | t) : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M12 21s-6.716-4.248-9.193-6.725A6 6 0 0 1 12 5.414 6 6 0 0 1 21.193 14.275C18.716 16.752 12 21 12 21z"/></svg>
<span [class.hidden]="collapsed">{{ 'nav.liked' | t }}</span>
</a>
</li>
</ul>
</div>
<!-- Fournisseurs -->
<div>
<div class="px-3 py-2 text-xs uppercase tracking-wide text-slate-400" [class.hidden]="collapsed">Fournisseurs</div>
<ul class="space-y-1">
<!-- All providers except PeerTube (order handled in TS so PT is last) -->
<ng-container *ngFor="let p of providersList()">
<li *ngIf="p.id !== 'peertube'">
<a [routerLink]="['/p', p.id, 't', 'trending']" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? (p.label) : null">
<span class="h-6 w-6 flex items-center justify-center">
<ng-container [ngSwitch]="p.id">
<!-- YouTube -->
<svg *ngSwitchCase="'youtube'" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="3" y="6" width="18" height="12" rx="3" class="text-red-600" fill="currentColor"/>
<polygon points="10,9 16,12 10,15" fill="white"/>
</svg>
<!-- Dailymotion -->
<svg *ngSwitchCase="'dailymotion'" class="h-5 w-5 text-blue-500" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="currentColor"/>
<polygon points="11,8 16,12 11,16" fill="white"/>
</svg>
<!-- Twitch -->
<svg *ngSwitchCase="'twitch'" class="h-5 w-5 text-purple-500" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M4 3h16v11l-4 4h-3l-2 2H9v-2H6V3zm3 2v9h6l2-2h3V5H7z" fill="currentColor"/>
</svg>
<!-- Rumble -->
<svg *ngSwitchCase="'rumble'" class="h-5 w-5 text-green-500" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="currentColor"/>
<polygon points="10,8 16,12 10,16" fill="white"/>
</svg>
<!-- Odysee -->
<svg *ngSwitchCase="'odysee'" class="h-5 w-5 text-pink-500" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="currentColor"/>
<circle cx="12" cy="12" r="5" fill="white"/>
</svg>
<!-- Fallback generic play icon -->
<svg *ngSwitchDefault class="h-5 w-5 text-slate-300" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<polygon points="9,7 17,12 9,17" fill="currentColor"/>
</svg>
</ng-container>
</span>
<span [class.hidden]="collapsed">{{ p.label }}</span>
</a>
</li>
</ng-container>
<!-- PeerTube group with instances as sub-items -->
<ng-container *ngFor="let p of providersList()">
<li [ngClass]="{ 'mt-2': true }" *ngIf="p.id === 'peertube'">
<a [routerLink]="['/p', 'peertube', 't', 'trending']" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? (p.label) : null">
<span class="h-6 w-6 flex items-center justify-center">
<!-- PeerTube logo style (orange play) -->
<svg class="h-5 w-5 text-orange-500" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="currentColor"/>
<polygon points="10,8 16,12 10,16" fill="white"/>
</svg>
</span>
<span [class.hidden]="collapsed">{{ p.label }}</span>
</a>
<!-- Sub-list of instances (visible when not collapsed) -->
<ul class="ml-8 mt-1 space-y-1" [class.hidden]="collapsed">
<li *ngFor="let inst of peerTubeInstances()">
<a [routerLink]="['/p', 'peertube', 't', 'trending']"
(click)="$event.preventDefault(); selectPeerTubeInstance(inst)"
class="flex items-center px-3 py-1.5 rounded hover:bg-slate-800 transition gap-2">
<svg class="h-4 w-4 text-orange-500" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="currentColor"/>
<polygon points="10,8 16,12 10,16" fill="white"/>
</svg>
<span class="text-xs" [ngClass]="{ 'text-sky-400': inst === activePeerTubeInstance(), 'text-slate-300': inst !== activePeerTubeInstance() }">{{ inst }}</span>
</a>
</li>
</ul>
</li>
</ng-container>
</ul>
</div>
<!-- Explorer (themes list with more/less) -->
<div>
<div class="px-3 py-2 text-xs uppercase tracking-wide text-slate-400" [class.hidden]="collapsed">{{ 'nav.explore' | t }}</div>
<ul class="space-y-1">
<li *ngFor="let t of displayedThemes()">
<a [routerLink]="['/t', t.slug]" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? (t.label) : null">
<span class="h-6 w-6 flex items-center justify-center text-lg">{{ t.emoji }}</span>
<span [class.hidden]="collapsed">{{ t.label }}</span>
</a>
</li>
</ul>
<button class="flex items-center gap-2 px-3 py-2 text-sm text-slate-300 hover:text-white" (click)="toggleThemes()" [class.hidden]="collapsed">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M12 5l7 7-7 7-1.41-1.41L15.17 13H5v-2h10.17l-4.58-4.59L12 5z"/></svg>
<span>{{ showAllThemes() ? ('common.less' | t) : ('common.more' | t) }}</span>
</button>
</div>
<!-- Informations -->
<div>
<div class="px-3 py-2 text-xs uppercase tracking-wide text-slate-400" [class.hidden]="collapsed">{{ 'nav.info' | t : 'Informations' }}</div>
<ul class="space-y-1">
<li>
<a routerLink="/info/utilisation" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.usage' | t : 'Utilisation') : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2zm1 15h-2v-6h2zm0-8h-2V7h2z"/></svg>
<span [class.hidden]="collapsed">{{ 'nav.usage' | t : 'Utilisation' }}</span>
</a>
</li>
<li>
<a href="https://git.dracodev.net/Projets/NewTube" target="_blank" rel="noopener noreferrer" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? 'Gitea' : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2zm1 14.59V20h-2v-3.41a4.994 4.994 0 0 1-4-4.9V7h2v4.69A3 3 0 0 0 12 14a3 3 0 0 0 3-2.31V7h2v4.69a4.994 4.994 0 0 1-4 4.9z"/></svg>
<span [class.hidden]="collapsed">Gitea</span>
</a>
</li>
</ul>
</div>
</div>
</nav>

View File

@ -0,0 +1,40 @@
import { ChangeDetectionStrategy, Component, computed, inject, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { InstanceService } from '../../services/instance.service';
import { ThemesService } from '../../services/themes.service';
import { TranslatePipe } from '../../pipes/translate.pipe';
@Component({
selector: 'app-sidebar',
standalone: true,
imports: [CommonModule, RouterLink, RouterLinkActive, TranslatePipe],
templateUrl: './sidebar.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SidebarComponent {
private instances = inject(InstanceService);
themes = inject(ThemesService);
provider = computed(() => this.instances.selectedProvider());
providerLabel = computed(() => this.instances.selectedProviderLabel());
// Providers with PeerTube last
providersList = computed(() => {
const list = this.instances.providers();
const others = list.filter(p => p.id !== 'peertube');
const peertube = list.find(p => p.id === 'peertube');
return peertube ? [...others, peertube] : list;
});
peerTubeInstances = computed(() => this.instances.peerTubeInstances());
activePeerTubeInstance = computed(() => this.instances.activePeerTubeInstance());
showAllThemes = this.themes.showAll;
displayedThemes = computed(() => this.showAllThemes() ? this.themes.themes() : this.themes.themes().slice(0, 8));
toggleThemes() { this.themes.toggleShowAll(); }
selectPeerTubeInstance(inst: string) {
if (!inst) return;
this.instances.setActivePeerTubeInstance(inst);
}
@Input() collapsed = false;
}

View File

@ -0,0 +1,162 @@
<div class="container mx-auto p-4 sm:p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2 text-slate-200">
<a [routerLink]="['/t', theme()]" class="px-3 py-1 rounded bg-slate-800 hover:bg-slate-700 border border-slate-700">← {{ 'themes.backToThemes' | t }}</a>
<h2 class="text-2xl font-bold">
{{ themes.bySlug(theme())?.emoji }} {{ themes.bySlug(theme())?.label }}
<span class="text-slate-400 text-base">— {{ provider() | titlecase }}</span>
</h2>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-slate-400">{{ 'themes.changeProvider' | t }}</label>
<select [ngModel]="provider()" (ngModelChange)="changeProvider($event)" class="bg-gray-800 text-white rounded px-2 py-1">
<option *ngFor="let p of providersList()" [value]="p.id">{{ p.label }}</option>
</select>
</div>
</div>
<!-- Filters -->
<div class="grid sm:grid-cols-4 gap-3 mb-6 bg-slate-800/50 p-3 rounded-lg border border-slate-700">
<div>
<label class="block text-xs text-slate-400 mb-1">{{ 'filter.sort' | t }}</label>
<select [ngModel]="sort()" (ngModelChange)="onSortChange($event)" class="w-full bg-gray-800 text-white rounded px-2 py-1">
<option value="recent">{{ 'filter.sort.recent' | t }}</option>
<option value="viewed">{{ 'filter.sort.viewed' | t }}</option>
<option value="longest">{{ 'filter.sort.longest' | t }}</option>
</select>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ 'filter.duration' | t }}</label>
<select [ngModel]="duration()" (ngModelChange)="onDurationChange($event)" class="w-full bg-gray-800 text-white rounded px-2 py-1">
<option value="any">{{ 'filter.duration.any' | t }}</option>
<option value="short">{{ 'filter.duration.short' | t }}</option>
<option value="medium">{{ 'filter.duration.medium' | t }}</option>
<option value="long">{{ 'filter.duration.long' | t }}</option>
</select>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ 'filter.language' | t }}</label>
<select [ngModel]="language()" (ngModelChange)="onLanguageChange($event)" class="w-full bg-gray-800 text-white rounded px-2 py-1">
<option value="any">Any</option>
<option value="en">English</option>
<option value="fr">Français</option>
</select>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ 'filter.date' | t }}</label>
<select [ngModel]="date()" (ngModelChange)="onDateChange($event)" class="w-full bg-gray-800 text-white rounded px-2 py-1">
<option value="any">Any</option>
<option value="day">Last 24h</option>
<option value="week">Last week</option>
<option value="month">Last month</option>
<option value="year">Last year</option>
</select>
</div>
</div>
<!-- Sections Tabs mimic (simple headings) -->
<div class="space-y-10">
<!-- Recorded Streams -->
<section>
<h3 class="text-xl font-semibold mb-3">{{ 'themes.recorded' | t }}</h3>
<div *ngIf="recorded.loading && !recorded.items.length" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div *ngFor="let i of [1,2,3,4,5,6,7,8]" class="animate-pulse bg-slate-800/50 rounded-lg overflow-hidden border border-slate-800">
<div class="w-full h-32 bg-slate-800"></div>
<div class="p-2">
<div class="h-3 bg-slate-700 rounded w-11/12 mb-2"></div>
<div class="h-3 bg-slate-700 rounded w-8/12"></div>
</div>
</div>
</div>
<div *ngIf="!recorded.loading && recorded.items.length === 0" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
<div *ngIf="recorded.error" class="mt-2 p-3 bg-red-900/30 border border-red-700 text-red-200 rounded">
{{ recorded.error }}
<button class="ml-2 underline hover:text-red-100" (click)="retry(recorded)">{{ 'action.retry' | t }}</button>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4" *ngIf="recorded.items.length">
<a *ngFor="let v of (recorded.items | slice:0:recorded.visibleCount); trackBy: trackByVideo"
[routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }"
class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50">
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full h-32 object-cover">
<div class="p-2 text-sm line-clamp-2">{{ v.title }}</div>
</a>
</div>
<!-- Infinite scroll anchor -->
<app-infinite-anchor
*ngIf="recorded.items.length && (recorded.items.length >= recorded.visibleCount)"
[busy]="recorded.loading"
[disabled]="!recorded.nextCursor && recorded.items.length <= recorded.visibleCount"
[rootMargin]="'1000px 0px 1000px 0px'"
(loadMore)="showMore(recorded)">
</app-infinite-anchor>
<div class="mt-2 flex items-center justify-center text-slate-400 text-sm" *ngIf="recorded.loading">
<span class="inline-block h-4 w-4 border-2 border-slate-500 border-t-transparent rounded-full animate-spin mr-2"></span>
{{ 'loading.more' | t }}
</div>
<div class="mt-3" *ngIf="recorded.items.length && (recorded.items.length > recorded.visibleCount || recorded.nextCursor)">
<button class="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded border border-slate-700 text-slate-200 disabled:opacity-60"
[disabled]="recorded.loading"
(click)="showMore(recorded)">
{{ 'loading.more' | t }} (20)
</button>
</div>
</section>
<!-- Videos -->
<section>
<h3 class="text-xl font-semibold mb-3">{{ 'themes.videos' | t }}</h3>
<div *ngIf="videos.loading && !videos.items.length" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div *ngFor="let i of [1,2,3,4,5,6,7,8]" class="animate-pulse bg-slate-800/50 rounded-lg overflow-hidden border border-slate-800">
<div class="w-full h-32 bg-slate-800"></div>
<div class="p-2">
<div class="h-3 bg-slate-700 rounded w-11/12 mb-2"></div>
<div class="h-3 bg-slate-700 rounded w-8/12"></div>
</div>
</div>
</div>
<div *ngIf="!videos.loading && videos.items.length === 0" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
<div *ngIf="videos.error" class="mt-2 p-3 bg-red-900/30 border border-red-700 text-red-200 rounded">
{{ videos.error }}
<button class="ml-2 underline hover:text-red-100" (click)="retry(videos)">{{ 'action.retry' | t }}</button>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4" *ngIf="videos.items.length">
<a *ngFor="let v of (videos.items | slice:0:videos.visibleCount); trackBy: trackByVideo"
[routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }"
class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50">
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full h-32 object-cover">
<div class="p-2 text-sm line-clamp-2">{{ v.title }}</div>
</a>
</div>
<!-- Infinite scroll anchor -->
<app-infinite-anchor
*ngIf="videos.items.length && (videos.items.length >= videos.visibleCount)"
[busy]="videos.loading"
[disabled]="!videos.nextCursor && videos.items.length <= videos.visibleCount"
[rootMargin]="'1000px 0px 1000px 0px'"
(loadMore)="showMore(videos)">
</app-infinite-anchor>
<div class="mt-2 flex items-center justify-center text-slate-400 text-sm" *ngIf="videos.loading">
<span class="inline-block h-4 w-4 border-2 border-slate-500 border-t-transparent rounded-full animate-spin mr-2"></span>
{{ 'loading.more' | t }}
</div>
<div class="mt-3" *ngIf="videos.items.length && (videos.items.length > videos.visibleCount || videos.nextCursor)">
<button class="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded border border-slate-700 text-slate-200 disabled:opacity-60"
[disabled]="videos.loading"
(click)="showMore(videos)">
{{ 'loading.more' | t }} (20)
</button>
</div>
</section>
<!-- Categories (static recommended) -->
<section>
<h3 class="text-xl font-semibold mb-3">{{ 'themes.categories' | t }}</h3>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div *ngFor="let c of categories()" class="bg-slate-800/60 rounded-xl p-4 border border-slate-700">
<div class="text-4xl">{{ c.emoji }}</div>
<div class="mt-2 font-semibold">{{ c.label }}</div>
</div>
</div>
</section>
</div>
</div>

View File

@ -0,0 +1,375 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, effect, inject, signal, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { InstanceService, Provider } from '../../services/instance.service';
import { ThemesService } from '../../services/themes.service';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { Video } from '../../models/video.model';
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
import { TranslatePipe } from '../../pipes/translate.pipe';
interface SectionState {
key: 'recorded' | 'videos' | 'categories';
titleKey: string;
items: Video[];
nextCursor: string | null;
loading: boolean;
error: string | null;
visibleCount: number; // how many items are currently shown
}
@Component({
selector: 'app-provider-theme',
standalone: true,
templateUrl: './provider-theme-page.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule, RouterLink, InfiniteAnchorComponent, TranslatePipe]
})
export class ProviderThemePageComponent implements OnDestroy {
private route = inject(ActivatedRoute);
private router = inject(Router);
private instances = inject(InstanceService);
themes = inject(ThemesService);
private api = inject(YoutubeApiService);
private cdr = inject(ChangeDetectorRef);
provider = signal<Provider>('youtube');
theme = signal<string>('trending');
// Guard to ignore stale async responses after route/theme/provider changes
private version = 0;
// Filters state
sort = signal<'recent' | 'viewed' | 'longest'>('recent');
duration = signal<'any' | 'short' | 'medium' | 'long'>('any');
language = signal<'any' | 'en' | 'fr'>('any');
date = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any');
// UI
providersList = computed(() => this.instances.providers());
categories = computed(() => this.themes.categoriesFor(this.theme()));
// Sections (Live removed on provider pages)
recorded: SectionState = { key: 'recorded', titleKey: 'themes.recorded', items: [], nextCursor: null, loading: false, error: null, visibleCount: 20 };
videos: SectionState = { key: 'videos', titleKey: 'themes.videos', items: [], nextCursor: null, loading: false, error: null, visibleCount: 20 };
constructor() {
this.route.paramMap.subscribe(pm => {
const provider = (pm.get('provider') as Provider) || 'youtube';
const theme = pm.get('theme') || 'trending';
this.provider.set(provider);
this.theme.set(theme);
// Keep global provider in sync so header search uses the current context
try { this.instances.setSelectedProvider(provider); } catch {}
this.resetAll();
// Increment version so in-flight requests from previous state are ignored
this.version++;
this.loadInitial();
this.cdr.markForCheck();
});
// Prefetch recalculation on resize (affects columns/rows thresholds)
this.handleResize = () => {
this.checkPrefetch(this.recorded);
this.checkPrefetch(this.videos);
};
try { window.addEventListener('resize', this.handleResize); } catch {}
// Re-apply filters on change (Live removed) with a tiny debounce to avoid thrashing
effect(() => {
const _s = this.sort();
const _d = this.duration();
const _l = this.language();
const _dt = this.date();
const apply = () => {
// Reset visible window to ensure top results reflect new sort/filter instantly
this.recorded.visibleCount = Math.max(20, this.recorded.visibleCount);
this.videos.visibleCount = Math.max(20, this.videos.visibleCount);
this.recorded.items = this.applyFilters(this.recorded.items);
this.videos.items = this.applyFilters(this.videos.items);
// After filtering, re-evaluate if we need to prefetch more
this.checkPrefetch(this.recorded);
this.checkPrefetch(this.videos);
this.cdr.markForCheck();
try { this.cdr.detectChanges(); } catch {}
};
try {
clearTimeout((this as any).__fltTimer);
} catch {}
(this as any).__fltTimer = setTimeout(apply, 150);
}, { allowSignalWrites: true });
}
private resetAll() {
for (const s of [this.recorded, this.videos]) {
s.items = []; s.nextCursor = null; s.loading = false; s.error = null; s.visibleCount = 20;
}
this.cdr.markForCheck();
}
private baseTokens(): string[] {
const t = this.theme();
if (t === 'trending') return [];
if (t === 'live') return ['live'];
return this.themes.tokensFor(t);
}
private buildQueryFor(section: SectionState): string {
const tokens = [...this.baseTokens()];
if (section.key === 'recorded') {
const p = this.provider();
// Only providers that benefit from a 'replay' search hint
if (p === 'youtube' || p === 'twitch') tokens.push('replay');
// For other providers, prefer trending (empty query) to avoid bad/no results
}
return tokens.join(' ').trim();
}
private applyFilters(list: Video[]): Video[] {
let arr = list.slice();
const d = this.duration();
if (d !== 'any') {
arr = arr.filter(v => {
const dur = v.duration || 0;
if (d === 'short') return dur > 0 && dur < 600;
if (d === 'medium') return dur >= 600 && dur < 1800;
if (d === 'long') return dur >= 1800;
return true;
});
}
// Language heuristic (very lightweight)
const lang = this.language();
if (lang !== 'any') {
const isFrWord = (s: string) => /\b(le|la|les|des|un|une|et|ou|pour|avec|sans|sur|dans|est|cette|vidéo)\b/i.test(s);
const isEnWord = (s: string) => /\b(the|a|an|and|or|with|without|for|this|video|live)\b/i.test(s);
arr = arr.filter(v => {
const text = `${v.title} ${v.uploaderName}`;
const fr = isFrWord(text) || /[éèêàùçîïô]/i.test(text);
const en = isEnWord(text);
return lang === 'fr' ? fr && !en : en && !fr;
});
}
// Date filter based on uploaded timestamp if available
const dateOpt = this.date();
if (dateOpt !== 'any') {
const now = Date.now();
const cutoff = (() => {
if (dateOpt === 'day') return now - 24 * 3600_000;
if (dateOpt === 'week') return now - 7 * 24 * 3600_000;
if (dateOpt === 'month') return now - 30 * 24 * 3600_000;
if (dateOpt === 'year') return now - 365 * 24 * 3600_000;
return 0;
})();
if (cutoff > 0) {
arr = arr.filter(v => (v.uploaded || 0) >= cutoff);
}
}
const s = this.sort();
if (s === 'recent') arr.sort((a, b) => (b.uploaded || 0) - (a.uploaded || 0));
if (s === 'viewed') arr.sort((a, b) => (b.views || 0) - (a.views || 0));
if (s === 'longest') arr.sort((a, b) => (b.duration || 0) - (a.duration || 0));
return arr;
}
changeProvider(p: any) {
const provider = String(p) as Provider;
// Sync the global provider so header search reflects the current context immediately
try { this.instances.setSelectedProvider(provider); } catch {}
this.router.navigate(['/p', provider, 't', this.theme()]);
}
onSortChange(val: any) { this.sort.set(val as any); }
onDurationChange(val: any) { this.duration.set(val as any); }
onLanguageChange(val: any) { this.language.set(val as any); }
onDateChange(val: any) { this.date.set(val as any); }
loadInitial() {
const readiness = this.instances.getProviderReadiness(this.provider());
if (!readiness.ready) {
const msg = readiness.reason || 'Provider not ready';
console.warn('[ProviderTheme] Provider not ready', { provider: this.provider(), reason: msg, theme: this.theme() });
this.recorded.error = msg; this.recorded.loading = false;
this.videos.error = msg; this.videos.loading = false;
return;
}
this.loadMore(this.recorded);
this.loadMore(this.videos);
}
loadMore(section: SectionState) {
if (section.loading) return;
const readiness = this.instances.getProviderReadiness(this.provider());
if (!readiness.ready) {
section.error = readiness.reason || 'Provider not ready';
console.warn('[ProviderTheme] Skip load: provider not ready', { provider: this.provider(), section: section.key, reason: section.error, theme: this.theme() });
section.loading = false;
return;
}
section.loading = true;
this.cdr.markForCheck();
const provider = this.provider();
const query = this.buildQueryFor(section);
const snapshotVersion = this.version;
// If YouTube quota has already been flagged exceeded, surface it and avoid requests
if (provider === 'youtube' && this.api.quotaExceeded$.getValue()) {
section.error = 'YouTube quota exceeded. Try again later or switch provider.';
section.loading = false;
console.warn('[ProviderTheme] Skip load: YouTube quota exceeded', { section: section.key, theme: this.theme() });
return;
}
const next = (res: { items: Video[]; nextCursor?: string | null }) => {
// Ignore responses from a previous state
if (snapshotVersion !== this.version) return;
// Deduplicate by videoId or URL before applying filters
const existing = new Set(section.items.map(v => v.videoId || v.url || ''));
const beforeLen = section.items.length;
const merged: Video[] = [...section.items];
const incoming = res.items || [];
const beforeKeys = existing.size;
for (const v of res.items || []) {
const key = v.videoId || v.url || '';
if (!existing.has(key)) { existing.add(key); merged.push(v); }
}
const afterKeys = existing.size;
const added = Math.max(0, afterKeys - beforeKeys);
const duplicates = Math.max(0, (incoming.length || 0) - added);
section.items = this.applyFilters(merged);
section.nextCursor = res.nextCursor || null;
section.loading = false;
if (incoming.length === 0) {
console.info('[ProviderTheme] Empty page', { provider, section: section.key, theme: this.theme(), query, cursor: section.nextCursor });
}
if (!res.nextCursor) {
console.info('[ProviderTheme] Reached end of pagination', { provider, section: section.key, theme: this.theme(), total: section.items.length });
}
if (duplicates > 0) {
console.debug('[ProviderTheme] Deduplicated incoming items', { provider, section: section.key, duplicates, added, before: beforeLen, after: section.items.length });
}
// Post-process for YouTube: fetch durations for items lacking them to make duration filters/sorts work
if (provider === 'youtube') {
try {
const wantIds = merged.filter(v => (v.duration || 0) === 0 && !!v.videoId).slice(0, 50).map(v => v.videoId as string);
const unique = Array.from(new Set(wantIds));
if (unique.length) {
this.api.getYouTubeDurations(unique).subscribe({
next: (mapDur) => {
if (snapshotVersion !== this.version) return;
if (!mapDur) return;
// Update durations in-place
for (const v of section.items) {
const id = v.videoId || '';
if (id && mapDur[id] != null) v.duration = Number(mapDur[id]) || 0;
}
// Re-apply filters/sorts that depend on duration
section.items = this.applyFilters(section.items);
this.checkPrefetch(section);
this.cdr.markForCheck();
},
error: () => {}
});
}
} catch {}
}
// Logic-based prefetch: if we are within ~2 rows of the end, prefetch next page
this.checkPrefetch(section);
this.cdr.markForCheck();
try { this.cdr.detectChanges(); } catch {}
};
const onErr = (e?: any) => {
// Specific message for quota exceeded on YouTube
if (provider === 'youtube' && e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
this.api.quotaExceeded$.next(true);
section.error = 'YouTube quota exceeded. Try again later or switch provider.';
} else if (provider === 'peertube') {
const inst = this.instances.activePeerTubeInstance();
const status = e?.status;
if (status === 404) section.error = `PeerTube (${inst}) introuvable. Essayez une autre instance.`;
else if (status === 429) section.error = `PeerTube (${inst}) limite atteinte. Réessayez plus tard.`;
else section.error = `PeerTube (${inst}) indisponible. Réessayez plus tard.`;
} else {
section.error = 'Erreur de chargement';
}
section.loading = false;
console.error('[ProviderTheme] Load error', { provider, section: section.key, theme: this.theme(), query, cursor: section.nextCursor, error: String(e?.message || e) });
this.cdr.markForCheck();
try { this.cdr.detectChanges(); } catch {}
};
if (query) {
this.api.searchVideosPage(query, section.nextCursor, provider).subscribe({ next, error: onErr });
} else {
this.api.getTrendingPage(section.nextCursor, provider).subscribe({ next, error: onErr });
}
}
// Increase visible items by 20; fetch the next page if we need more data
showMore(section: SectionState) {
const target = section.visibleCount + 20;
section.visibleCount = target;
// If we don't have enough items yet and there is a next page, fetch it
if (section.items.length < target && section.nextCursor && !section.loading) {
console.debug('[ProviderTheme] showMore triggers fetch', { section: section.key, target, have: section.items.length, nextCursor: section.nextCursor });
this.loadMore(section);
}
// Additionally, run logic-based prefetch check in case of edge conditions
this.checkPrefetch(section);
this.cdr.markForCheck();
}
// Helpers for watch params
watchQueryParams(v: Video): Record<string, any> | null {
const p = this.provider();
const qp: any = { p };
if (p === 'odysee' && v.url?.startsWith('https://odysee.com/')) {
let slug = v.url.substring('https://odysee.com/'.length);
if (slug.startsWith('/')) slug = slug.slice(1);
qp.slug = slug;
}
if (p === 'twitch' && v.type === 'channel') qp.channel = v.videoId;
return qp;
}
// trackBy to reduce DOM churn
trackByVideo(_idx: number, v: Video) { return v.videoId || v.url || _idx; }
// Manual retry helper
retry(section: SectionState) {
section.error = null;
this.loadMore(section);
}
// --- Prefetch helpers ---
private handleResize: (() => void) | null = null;
private currentCols(): number {
try {
// Tailwind breakpoints: md>=768px -> 4 cols, lg>=1024px -> 6 cols, else 2 cols
if (window.matchMedia && window.matchMedia('(min-width: 1024px)').matches) return 6;
if (window.matchMedia && window.matchMedia('(min-width: 768px)').matches) return 4;
return 2;
} catch {
return 4; // safe default
}
}
private checkPrefetch(section: SectionState) {
const cols = this.currentCols();
const threshold = cols * 2; // ~2 rows
const remaining = section.items.length - section.visibleCount;
if (remaining <= threshold && section.nextCursor && !section.loading) {
console.debug('[ProviderTheme] Logic prefetch triggered', { section: section.key, remaining, threshold, nextCursor: section.nextCursor });
this.loadMore(section);
}
}
ngOnDestroy(): void {
try { if (this.handleResize) window.removeEventListener('resize', this.handleResize); } catch {}
}
}

View File

@ -0,0 +1,44 @@
<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 flex items-center gap-2">
<span>{{ themes.bySlug(themeSlug())?.emoji }}</span>
<span>{{ themeLabel() }}</span>
</h2>
<div class="space-y-10">
<section *ngFor="let b of blocks()">
<div class="flex items-center justify-between mb-3">
<h3 class="text-xl font-semibold">{{ b.label }}</h3>
<a [routerLink]="['/p', b.provider, 't', themeSlug()]" class="text-sm px-3 py-1 rounded bg-slate-800 hover:bg-slate-700 border border-slate-700">{{ 'themes.viewAll' | t }}</a>
</div>
<!-- Loading state -->
<div *ngIf="b.loading" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<div *ngFor="let i of [1,2,3,4,5,6,7,8,9,10]" class="animate-pulse bg-slate-800 rounded-lg h-40"></div>
</div>
<!-- Error / empty -->
<div *ngIf="!b.loading && b.error" class="text-amber-300">{{ b.error }}</div>
<div *ngIf="!b.loading && !b.error && b.items.length === 0" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
<!-- Grid -->
<div *ngIf="!b.loading && !b.error && b.items.length" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<a *ngFor="let v of b.items"
[routerLink]="['/watch', v.videoId]"
[queryParams]="watchQueryParams(b.provider, v)"
[state]="{ video: v }"
class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300">
<div class="relative">
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full h-40 object-cover">
<div class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-0.5 rounded" *ngIf="v.duration">
{{ v.duration / 60 | number:'1.0-0' }}:{{ (v.duration % 60) | number:'2.0-0' }}
</div>
</div>
<div class="p-3">
<h4 class="font-semibold line-clamp-2">{{ v.title }}</h4>
<div class="text-sm text-slate-400 mt-1">{{ v.uploaderName }}</div>
</div>
</a>
</div>
</section>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More