DRAFT: Add Tubearchivist Frontend React dev docker setup (#768)

* Add development docker-compose file

* Add /new/ path in nginx conf

* Add frontend production setup

* Fix lint

* Refac move prod docker compose into non suffixed file

* Fix run.sh fileendings on windows

* Fix docker file naming consistancies

* Add frontend dev setup

* Add docker compose dev command

* Refac remove docker network

* Fix potential error causes

* Chore update react-router-dom

* Add redirect to login after logout

* Refac allow basic auth for session login in api

* Fix loginresponsetype optional property

* Refac move isAdmin check into page Base

* Refac use node lts for dev container

* Refac remove old setup in readme

* Refac move getisAdmin into loader and rename

* Refac remove manual csrf cookie handing from actions and loader

* Fix post requiring csrf header & cookie

* Fix remove empty files

* Refac revert dockerfile changes

* Refac revert gitatrributes changes

* Refac revert docker changes

* Refac revert nginx changes

* Refac revert docker change

* Refac move frontend into frontend folder

* Add production steps to dockerfile

* Refac implement endpoint renaming

* Refac remove frontend dockerfile

* Add credentials include for dev env

* Fix allow cors with credentials for dev environment

* Fix images in dev mode

* Add credentials for dev mode to all loader and actions, except signin

* Revert cors config

* Revert cors config

* Fix nginx not serving /youtube/

* Fix video url missing api

* Fix media url missing api

* Add application settings page

* Add continue vids

* Add csrf to delete requests

* Refac use api/video endpoint with filter to home, channel, playlist pages

* Add channel nav request

* Add channel playlists

* Fix filterbar for playlist in channel

* Add playlist_nav to video page

* Add downloads aggs

* Refac remove basic auth

* Fix credentials include in signin

* Refac user config to user me config

* Add ApiToken get
This commit is contained in:
Merlin
2024-08-10 19:53:50 +02:00
committed by GitHub
parent 4dd7ac496a
commit 83bb7f678b
221 changed files with 16165 additions and 2 deletions

View File

@@ -0,0 +1,30 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
import isDevEnvironment from '../../functions/isDevEnvironment';
const createCustomPlaylist = async (playlistId: string) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/playlist/`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
body: JSON.stringify({ data: { create: playlistId } }),
});
const customPlaylist = await response.json();
if (isDevEnvironment()) {
console.log('createCustomPlaylist', customPlaylist);
}
return customPlaylist;
};
export default createCustomPlaylist;

View File

@@ -0,0 +1,28 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
import isDevEnvironment from '../../functions/isDevEnvironment';
const deleteApiToken = async () => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/appsettings/token/`, {
method: 'DELETE',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
});
const resetToken = await response.json();
if (isDevEnvironment()) {
console.log('deleteApiToken', resetToken);
}
return resetToken;
};
export default deleteApiToken;

View File

@@ -0,0 +1,29 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
import isDevEnvironment from '../../functions/isDevEnvironment';
const deleteChannel = async (channelId: string) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/channel/${channelId}/`, {
method: 'DELETE',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
});
const channelDeleted = await response.json();
if (isDevEnvironment()) {
console.log('deleteChannel', channelDeleted);
}
return channelDeleted;
};
export default deleteChannel;

View File

@@ -0,0 +1,29 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
import isDevEnvironment from '../../functions/isDevEnvironment';
const deleteDownloadById = async (youtubeId: string) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/download/${youtubeId}/`, {
method: 'DELETE',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
});
const downloadState = await response.json();
if (isDevEnvironment()) {
console.log('deleteDownloadById', downloadState);
}
return downloadState;
};
export default deleteDownloadById;

View File

@@ -0,0 +1,37 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
import isDevEnvironment from '../../functions/isDevEnvironment';
type FilterType = 'ignore' | 'pending';
const deleteDownloadQueueByFilter = async (filter: FilterType) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const searchParams = new URLSearchParams();
if (filter) {
searchParams.append('filter', filter);
}
const response = await fetch(`${apiUrl}/api/download/?${searchParams.toString()}`, {
method: 'DELETE',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
});
const downloadState = await response.json();
if (isDevEnvironment()) {
console.log('deleteDownloadQueueByFilter', downloadState);
}
return downloadState;
};
export default deleteDownloadQueueByFilter;

View File

@@ -0,0 +1,34 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
import isDevEnvironment from '../../functions/isDevEnvironment';
const deletePlaylist = async (playlistId: string, allVideos = false) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
let params = '';
if (allVideos) {
params = '?delete-videos=true';
}
const response = await fetch(`${apiUrl}/api/playlist/${playlistId}/${params}`, {
method: 'DELETE',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
});
const playlistDeleted = await response.json();
if (isDevEnvironment()) {
console.log('deletePlaylist', playlistDeleted);
}
return playlistDeleted;
};
export default deletePlaylist;

View File

@@ -0,0 +1,29 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
import isDevEnvironment from '../../functions/isDevEnvironment';
const deleteVideo = async (videoId: string) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/video/${videoId}/`, {
method: 'DELETE',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
});
const videoDeleted = await response.json();
if (isDevEnvironment()) {
console.log('deleteVideo', videoDeleted);
}
return videoDeleted;
};
export default deleteVideo;

View File

@@ -0,0 +1,29 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
import isDevEnvironment from '../../functions/isDevEnvironment';
const deleteVideoProgressById = async (youtubeId: string) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/video/${youtubeId}/progress/`, {
method: 'DELETE',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
});
const watchedState = await response.json();
if (isDevEnvironment()) {
console.log('deleteVideoProgressById', watchedState);
}
return watchedState;
};
export default deleteVideoProgressById;

View File

@@ -0,0 +1,29 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
import isDevEnvironment from '../../functions/isDevEnvironment';
const queueBackup = async () => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/appsettings/backup/`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
});
const backupQueued = await response.json();
if (isDevEnvironment()) {
console.log('queueBackup', backupQueued);
}
return backupQueued;
};
export default queueBackup;

View File

@@ -0,0 +1,48 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
import isDevEnvironment from '../../functions/isDevEnvironment';
export type ReindexType = 'channel' | 'video' | 'playlist';
export const ReindexTypeEnum = {
channel: 'channel',
video: 'video',
playlist: 'playlist',
};
const queueReindex = async (id: string, type: ReindexType, reindexVideos = false) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
let params = '';
if (reindexVideos) {
params = '?extract_videos=true';
}
const body = JSON.stringify({
[type]: id,
});
const response = await fetch(`${apiUrl}/api/refresh/${params}`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
body,
});
const channelDeleted = await response.json();
if (isDevEnvironment()) {
console.log('queueReindex', channelDeleted);
}
return channelDeleted;
};
export default queueReindex;

View File

@@ -0,0 +1,29 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
import isDevEnvironment from '../../functions/isDevEnvironment';
const queueSnapshot = async () => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/appsettings/snapshot/`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
});
const snapshotQueued = await response.json();
if (isDevEnvironment()) {
console.log('queueSnapshot', snapshotQueued);
}
return snapshotQueued;
};
export default queueSnapshot;

View File

@@ -0,0 +1,29 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
import isDevEnvironment from '../../functions/isDevEnvironment';
const restoreBackup = async (fileName: string) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/appsettings/backup/${fileName}/`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
});
const backupRestored = await response.json();
if (isDevEnvironment()) {
console.log('restoreBackup', backupRestored);
}
return backupRestored;
};
export default restoreBackup;

View File

@@ -0,0 +1,29 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
import isDevEnvironment from '../../functions/isDevEnvironment';
const restoreSnapshot = async (snapshotId: string) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/appsettings/snapshot/${snapshotId}/`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
});
const backupRestored = await response.json();
if (isDevEnvironment()) {
console.log('restoreSnapshot', backupRestored);
}
return backupRestored;
};
export default restoreSnapshot;

View File

@@ -0,0 +1,39 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
export type LoginResponseType = {
token?: string;
user_id: number;
is_superuser: boolean;
is_staff: boolean;
user_groups: [];
};
const signIn = async (username: string, password: string, saveLogin: boolean) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/user/login/`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
body: JSON.stringify({
username,
password,
remember_me: saveLogin ? 'on' : 'off',
}),
});
if (response.status === 403) {
console.log('Might be already logged in.', await response.json());
}
return response;
};
export default signIn;

View File

@@ -0,0 +1,27 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
const stopTaskByName = async (taskId: string) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/task/by-id/${taskId}/`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
body: JSON.stringify({ command: 'stop' }),
});
const downloadQueueState = await response.json();
console.log('stopTaskByName', downloadQueueState);
return downloadQueueState;
};
export default stopTaskByName;

View File

@@ -0,0 +1,27 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
import { AppSettingsConfigType } from '../loader/loadAppsettingsConfig';
const updateAppsettingsConfig = async (config: AppSettingsConfigType) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/appsettings/config/`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
body: JSON.stringify(config),
});
const appSettingsConfig = await response.json();
console.log('updateAppsettingsConfig', appSettingsConfig);
return appSettingsConfig;
};
export default updateAppsettingsConfig;

View File

@@ -0,0 +1,29 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
const updateChannelSubscription = async (channelId: string, status: boolean) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/channel/`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
body: JSON.stringify({
data: [{ channel_id: channelId, channel_subscribed: status }],
}),
});
const channelSubscription = await response.json();
console.log('updateChannelSubscription', channelSubscription);
return channelSubscription;
};
export default updateChannelSubscription;

View File

@@ -0,0 +1,32 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
export type ValidatedCookieType = {
cookie_enabled: boolean;
status: boolean;
validated: number;
validated_str: string;
};
const updateCookie = async (): Promise<ValidatedCookieType> => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/appsettings/cookie/`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
});
const validatedCookie = await response.json();
console.log('updateCookie', validatedCookie);
return validatedCookie;
};
export default updateCookie;

View File

@@ -0,0 +1,33 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
type CustomPlaylistActionType = 'create' | 'up' | 'down' | 'top' | 'bottom' | 'remove';
const updateCustomPlaylist = async (
action: CustomPlaylistActionType,
playlistId: string,
videoId: string,
) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/playlist/${playlistId}/`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
body: JSON.stringify({ action, video_id: videoId }),
});
const customPlaylist = await response.json();
console.log('updateCustomPlaylist', action, customPlaylist);
return customPlaylist;
};
export default updateCustomPlaylist;

View File

@@ -0,0 +1,34 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
const updateDownloadQueue = async (download: string, autostart: boolean) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
let params = '';
if (autostart) {
params = '?autostart=true';
}
const response = await fetch(`${apiUrl}/api/download/${params}`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
body: JSON.stringify({
data: [{ youtube_id: download, status: 'pending' }],
}),
});
const downloadState = await response.json();
console.log('updateDownloadQueue', downloadState);
return downloadState;
};
export default updateDownloadQueue;

View File

@@ -0,0 +1,31 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
export type DownloadQueueStatus = 'ignore' | 'pending' | 'priority';
const updateDownloadQueueStatusById = async (youtubeId: string, status: DownloadQueueStatus) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/download/${youtubeId}/`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
body: JSON.stringify({
status,
}),
});
const downloadQueueState = await response.json();
console.log('updateDownloadQueueStatusById', downloadQueueState);
return downloadQueueState;
};
export default updateDownloadQueueStatusById;

View File

@@ -0,0 +1,29 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
const updatePlaylistSubscription = async (playlistId: string, status: boolean) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/playlist/`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
body: JSON.stringify({
data: [{ playlist_id: playlistId, playlist_subscribed: status }],
}),
});
const playlistSubscription = await response.json();
console.log('updatePlaylistSubscription', playlistSubscription);
return playlistSubscription;
};
export default updatePlaylistSubscription;

View File

@@ -0,0 +1,32 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
type TaskNamesType =
| 'download_pending'
| 'update_subscribed'
| 'manual_import'
| 'resync_thumbs'
| 'rescan_filesystem';
const updateTaskByName = async (taskName: TaskNamesType) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/task/by-name/${taskName}/`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
});
const downloadQueueState = await response.json();
console.log('updateTaskByName', downloadQueueState);
return downloadQueueState;
};
export default updateTaskByName;

View File

@@ -0,0 +1,56 @@
import { ColourVariants } from '../../configuration/colours/getColours';
import { SortByType, SortOrderType, ViewLayoutType } from '../../pages/Home';
import getApiUrl from '../../configuration/getApiUrl';
import defaultHeaders from '../../configuration/defaultHeaders';
import getCookie from '../../functions/getCookie';
import getFetchCredentials from '../../configuration/getFetchCredentials';
export type UserMeType = {
id: number;
name: string;
is_superuser: boolean;
is_staff: boolean;
groups: [];
user_permissions: [];
last_login: string;
config: UserConfigType;
};
export type UserConfigType = {
stylesheet?: ColourVariants;
page_size?: number;
sort_by?: SortByType;
sort_order?: SortOrderType;
view_style_home?: ViewLayoutType;
view_style_channel?: ViewLayoutType;
view_style_downloads?: ViewLayoutType;
view_style_playlist?: ViewLayoutType;
grid_items?: number;
hide_watched?: boolean;
show_ignored_only?: boolean;
show_subed_only?: boolean;
sponsorblock_id?: number;
};
const updateUserConfig = async (config: UserConfigType): Promise<UserMeType> => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/user/me/`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
body: JSON.stringify({ config }),
});
const userConfig = await response.json();
console.log('updateUserConfig', userConfig);
return userConfig;
};
export default updateUserConfig;

View File

@@ -0,0 +1,34 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
type VideoProgressProp = {
youtubeId: string;
currentProgress: number;
};
const updateVideoProgressById = async ({ youtubeId, currentProgress }: VideoProgressProp) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/video/${youtubeId}/progress/`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
body: JSON.stringify({
position: currentProgress,
}),
});
const userConfig = await response.json();
console.log('updateVideoProgressById', userConfig);
return userConfig;
};
export default updateVideoProgressById;

View File

@@ -0,0 +1,37 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
import deleteVideoProgressById from './deleteVideoProgressById';
export type Watched = {
id: string;
is_watched: boolean;
};
const updateWatchedState = async (watched: Watched) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
if (watched.is_watched) {
await deleteVideoProgressById(watched.id);
}
const response = await fetch(`${apiUrl}/api/watched/`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
body: JSON.stringify(watched),
});
const watchedState = await response.json();
console.log('updateWatchedState', watchedState);
return watchedState;
};
export default updateWatchedState;

View File

@@ -0,0 +1,32 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
import isDevEnvironment from '../../functions/isDevEnvironment';
type ApiTokenResponse = {
token: string;
};
const loadApiToken = async (): Promise<ApiTokenResponse> => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/appsettings/token/`, {
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
});
const apiToken = await response.json();
if (isDevEnvironment()) {
console.log('loadApiToken', apiToken);
}
return apiToken;
};
export default loadApiToken;

View File

@@ -0,0 +1,54 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
export type AppSettingsConfigType = {
subscriptions: {
channel_size: number;
live_channel_size: number;
shorts_channel_size: number;
auto_start: boolean;
};
downloads: {
limit_speed: boolean | number;
sleep_interval: number;
autodelete_days: boolean | number;
format: boolean | string;
format_sort: boolean | string;
add_metadata: boolean;
add_thumbnail: boolean;
subtitle: boolean | string;
subtitle_source: boolean | string;
subtitle_index: boolean;
comment_max: boolean | number;
comment_sort: string;
cookie_import: boolean;
throttledratelimit: boolean | number;
extractor_lang: boolean | string;
integrate_ryd: boolean;
integrate_sponsorblock: boolean;
};
application: {
enable_snapshot: boolean;
};
};
const loadAppsettingsConfig = async (): Promise<AppSettingsConfigType> => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/appsettings/config/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const appSettingsConfig = await response.json();
if (isDevEnvironment()) {
console.log('loadApplicationConfig', appSettingsConfig);
}
return appSettingsConfig;
};
export default loadAppsettingsConfig;

View File

@@ -0,0 +1,21 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
const loadAuth = async () => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/ping/`, {
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
});
return response;
};
export default loadAuth;

View File

@@ -0,0 +1,23 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
const loadBackupList = async () => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/appsettings/backup/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const backupList = await response.json();
if (isDevEnvironment()) {
console.log('loadBackupList', backupList);
}
return backupList;
};
export default loadBackupList;

View File

@@ -0,0 +1,23 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
const loadChannelById = async (youtubeChannelId: string) => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/channel/${youtubeChannelId}/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const channel = await response.json();
if (isDevEnvironment()) {
console.log('loadChannelById', channel);
}
return channel;
};
export default loadChannelById;

View File

@@ -0,0 +1,33 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
const loadChannelList = async (page: number, showSubscribed: boolean) => {
const apiUrl = getApiUrl();
const searchParams = new URLSearchParams();
if (page) {
searchParams.append('page', page.toString());
}
if (showSubscribed) {
searchParams.append('filter', 'subscribed');
}
const response = await fetch(`${apiUrl}/api/channel/?${searchParams.toString()}`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const channels = await response.json();
if (isDevEnvironment()) {
console.log('loadChannelList', channels);
}
return channels;
};
export default loadChannelList;

View File

@@ -0,0 +1,30 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
export type ChannelNavResponseType = {
has_streams: boolean;
has_shorts: boolean;
has_playlists: boolean;
has_pending: boolean;
};
const loadChannelNav = async (youtubeChannelId: string): Promise<ChannelNavResponseType> => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/channel/${youtubeChannelId}/nav/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const channel = await response.json();
if (isDevEnvironment()) {
console.log('loadChannelNav', channel);
}
return channel;
};
export default loadChannelNav;

View File

@@ -0,0 +1,23 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
const loadCommentsbyVideoId = async (youtubeId: string) => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/video/${youtubeId}/comment/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const comments = await response.json();
if (isDevEnvironment()) {
console.log('loadCommentsbyVideoId', comments);
}
return comments;
};
export default loadCommentsbyVideoId;

View File

@@ -0,0 +1,37 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
type DownloadAggsBucket = {
key: string[];
key_as_string: string;
doc_count: number;
};
export type DownloadAggsType = {
channel_downloads: {
doc_count_error_upper_bound: number;
sum_other_doc_count: number;
buckets: DownloadAggsBucket[];
};
};
const loadDownloadAggs = async (): Promise<DownloadAggsType> => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/download/aggs/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const downloadAggs = await response.json();
if (isDevEnvironment()) {
console.log('loadDownloadAggs', downloadAggs);
}
return downloadAggs;
};
export default loadDownloadAggs;

View File

@@ -0,0 +1,35 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
const loadDownloadQueue = async (page: number, channelId: string | null, showIgnored: boolean) => {
const apiUrl = getApiUrl();
const searchParams = new URLSearchParams();
if (page) {
searchParams.append('page', page.toString());
}
if (channelId) {
searchParams.append('channel', channelId);
}
searchParams.append('filter', showIgnored ? 'ignore' : 'pending');
const response = await fetch(`${apiUrl}/api/download/?${searchParams.toString()}`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const playlist = await response.json();
if (isDevEnvironment()) {
console.log('loadDownloadQueue', playlist);
}
return playlist;
};
export default loadDownloadQueue;

View File

@@ -0,0 +1,30 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
export type NotificationPages = 'download' | 'settings' | 'channel' | 'all';
const loadNotifications = async (pageName: NotificationPages, includeReindex = false) => {
const apiUrl = getApiUrl();
let params = '';
if (!includeReindex && pageName !== 'all') {
params = `?filter=${pageName}`;
}
const response = await fetch(`${apiUrl}/api/notification/${params}`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const notifications = await response.json();
if (isDevEnvironment()) {
console.log('loadNotifications', notifications);
}
return notifications;
};
export default loadNotifications;

View File

@@ -0,0 +1,23 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
const loadPlaylistById = async (playlistId: string | undefined) => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/playlist/${playlistId}/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const videos = await response.json();
if (isDevEnvironment()) {
console.log('loadPlaylistById', videos);
}
return videos;
};
export default loadPlaylistById;

View File

@@ -0,0 +1,50 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
type PlaylistType = 'regular' | 'custom';
type LoadPlaylistListProps = {
channel?: string;
page?: number | undefined;
subscribed?: boolean;
type?: PlaylistType;
};
const loadPlaylistList = async ({ channel, page, subscribed, type }: LoadPlaylistListProps) => {
const apiUrl = getApiUrl();
const searchParams = new URLSearchParams();
if (channel) {
searchParams.append('channel', channel);
}
if (page) {
searchParams.append('page', page.toString());
}
if (subscribed) {
searchParams.append('subscribed', subscribed.toString());
}
if (type) {
searchParams.append('type', type);
}
const response = await fetch(`${apiUrl}/api/playlist/?${searchParams.toString()}`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const playlist = await response.json();
if (isDevEnvironment()) {
console.log('loadPlaylistList', playlist);
}
return playlist;
};
export default loadPlaylistList;

View File

@@ -0,0 +1,23 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
const loadSearch = async (query: string) => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/search/?query=${query}`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const searchResults = await response.json();
if (isDevEnvironment()) {
console.log('loadSearch', searchResults);
}
return searchResults;
};
export default loadSearch;

View File

@@ -0,0 +1,23 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
const loadSimmilarVideosById = async (youtubeId: string) => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/video/${youtubeId}/similar/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const videos = await response.json();
if (isDevEnvironment()) {
console.log('loadSimmilarVideosById', videos);
}
return videos;
};
export default loadSimmilarVideosById;

View File

@@ -0,0 +1,23 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
const loadSnapshots = async () => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/appsettings/snapshot/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const backupList = await response.json();
if (isDevEnvironment()) {
console.log('loadSnapshots', backupList);
}
return backupList;
};
export default loadSnapshots;

View File

@@ -0,0 +1,23 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
const loadSponsorblockByVideoId = async (youtubeId: string) => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/video/${youtubeId}/sponsor/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const videos = await response.json();
if (isDevEnvironment()) {
console.log('loadSponsorblockByVideoId', videos);
}
return videos;
};
export default loadSponsorblockByVideoId;

View File

@@ -0,0 +1,28 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
type BiggestChannelsOrderType = 'doc_count' | 'duration' | 'media_size';
const loadStatsBiggestChannels = async (order: BiggestChannelsOrderType) => {
const apiUrl = getApiUrl();
const searchParams = new URLSearchParams();
searchParams.append('order', order);
const response = await fetch(`${apiUrl}/api/stats/biggestchannels/?${searchParams.toString()}`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const notifications = await response.json();
if (isDevEnvironment()) {
console.log('loadStatsBiggestChannels', notifications);
}
return notifications;
};
export default loadStatsBiggestChannels;

View File

@@ -0,0 +1,23 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
const loadStatsChannel = async () => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/stats/channel/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const notifications = await response.json();
if (isDevEnvironment()) {
console.log('loadStatsChannel', notifications);
}
return notifications;
};
export default loadStatsChannel;

View File

@@ -0,0 +1,23 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
const loadStatsDownload = async () => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/stats/download/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const notifications = await response.json();
if (isDevEnvironment()) {
console.log('loadStatsDownload', notifications);
}
return notifications;
};
export default loadStatsDownload;

View File

@@ -0,0 +1,23 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
const loadStatsDownloadHistory = async () => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/stats/downloadhist/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const notifications = await response.json();
if (isDevEnvironment()) {
console.log('loadStatsDownloadHistory', notifications);
}
return notifications;
};
export default loadStatsDownloadHistory;

View File

@@ -0,0 +1,23 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
const loadStatsPlaylist = async () => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/stats/playlist/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const notifications = await response.json();
if (isDevEnvironment()) {
console.log('loadStatsPlaylist', notifications);
}
return notifications;
};
export default loadStatsPlaylist;

View File

@@ -0,0 +1,23 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
const loadStatsVideo = async () => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/stats/video/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const notifications = await response.json();
if (isDevEnvironment()) {
console.log('loadStatsVideo', notifications);
}
return notifications;
};
export default loadStatsVideo;

View File

@@ -0,0 +1,23 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
const loadStatsWatchProgress = async () => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/stats/watch/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const notifications = await response.json();
if (isDevEnvironment()) {
console.log('loadStatsWatchProgress', notifications);
}
return notifications;
};
export default loadStatsWatchProgress;

View File

@@ -0,0 +1,24 @@
import { UserMeType } from '../actions/updateUserConfig';
import isDevEnvironment from '../../functions/isDevEnvironment';
import getApiUrl from '../../configuration/getApiUrl';
import defaultHeaders from '../../configuration/defaultHeaders';
import getFetchCredentials from '../../configuration/getFetchCredentials';
const loadUserMeConfig = async (): Promise<UserMeType> => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/user/me/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const userConfig = await response.json();
if (isDevEnvironment()) {
console.log('loadUserMeConfig', userConfig);
}
return userConfig;
};
export default loadUserMeConfig;

View File

@@ -0,0 +1,23 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
const loadVideoById = async (youtubeId: string) => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/video/${youtubeId}/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const videos = await response.json();
if (isDevEnvironment()) {
console.log('loadVideoById', videos);
}
return videos;
};
export default loadVideoById;

View File

@@ -0,0 +1,65 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
import { SortByType, SortOrderType } from '../../pages/Home';
type WatchTypes = 'watched' | 'unwatched' | 'continue';
type VideoTypes = 'videos' | 'streams' | 'shorts';
type FilterType = {
page?: number;
playlist?: string;
channel?: string;
watch?: WatchTypes;
sort?: SortByType;
order?: SortOrderType;
type?: VideoTypes;
};
const loadVideoListByFilter = async (filter: FilterType) => {
const apiUrl = getApiUrl();
const searchParams = new URLSearchParams();
if (filter.page) {
searchParams.append('page', filter.page.toString());
}
if (filter.playlist) {
searchParams.append('playlist', filter.playlist);
} else if (filter.channel) {
searchParams.append('channel', filter.channel);
}
if (filter.watch) {
searchParams.append('watch', filter.watch);
}
if (filter.sort) {
searchParams.append('sort', filter.sort);
}
if (filter.order) {
searchParams.append('order', filter.order);
}
if (filter.type) {
searchParams.append('type', filter.type);
}
const response = await fetch(`${apiUrl}/api/video/?${searchParams.toString()}`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const videos = await response.json();
if (isDevEnvironment()) {
console.log('loadVideoListByFilter', filter, videos);
}
return videos;
};
export default loadVideoListByFilter;

View File

@@ -0,0 +1,48 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
export type VideoNavResponseType = {
playlist_meta: {
current_idx: number;
playlist_id: string;
playlist_name: string;
playlist_channel: string;
};
playlist_previous: {
youtube_id: string;
title: string;
uploader: string;
idx: number;
downloaded: boolean;
vid_thumb: string;
};
playlist_next: {
youtube_id: string;
title: string;
uploader: string;
idx: number;
downloaded: boolean;
vid_thumb: string;
};
};
const loadVideoNav = async (youtubeVideoId: string): Promise<VideoNavResponseType[]> => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/video/${youtubeVideoId}/nav/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const videoNav = await response.json();
if (isDevEnvironment()) {
console.log('loadVideoNav', videoNav);
}
return videoNav;
};
export default loadVideoNav;

View File

@@ -0,0 +1,23 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
const loadVideoProgressById = async (youtubeId: string) => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/video/${youtubeId}/progress/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const videoProgress = await response.json();
if (isDevEnvironment()) {
console.log('loadVideoProgressById', videoProgress);
}
return videoProgress;
};
export default loadVideoProgressById;

View File

@@ -0,0 +1,57 @@
import { Fragment } from 'react';
import StatsInfoBoxItem from './StatsInfoBoxItem';
import formatNumbers from '../functions/formatNumbers';
import { ChannelStatsType, PlaylistStatsType, DownloadStatsType } from '../pages/SettingsDashboard';
type ApplicationStatsProps = {
channelStats?: ChannelStatsType;
playlistStats?: PlaylistStatsType;
downloadStats?: DownloadStatsType;
};
const ApplicationStats = ({
channelStats,
playlistStats,
downloadStats,
}: ApplicationStatsProps) => {
if (!channelStats || !playlistStats || !downloadStats) {
return <p id="loading">Loading...</p>;
}
const cards = [
{
title: 'Channels: ',
data: {
Subscribed: formatNumbers(channelStats.subscribed_true || 0),
Active: formatNumbers(channelStats.active_true || 0),
Total: formatNumbers(channelStats.doc_count || 0),
},
},
{
title: 'Playlists: ',
data: {
Subscribed: formatNumbers(playlistStats.subscribed_true || 0),
Active: formatNumbers(playlistStats.active_true || 0),
Total: formatNumbers(playlistStats.doc_count || 0),
},
},
{
title: `Downloads Pending: ${downloadStats.pending || 0}`,
data: {
Videos: formatNumbers(downloadStats.pending_videos || 0),
Shorts: formatNumbers(downloadStats.pending_shorts || 0),
Streams: formatNumbers(downloadStats.pending_streams || 0),
},
},
];
return cards.map(card => {
return (
<Fragment key={card.title}>
<StatsInfoBoxItem title={card.title} card={card.data} />
</Fragment>
);
});
};
export default ApplicationStats;

View File

@@ -0,0 +1,108 @@
import humanFileSize from '../functions/humanFileSize';
import formatNumbers from '../functions/formatNumbers';
import { Link } from 'react-router-dom';
import Routes from '../configuration/routes/RouteList';
import { BiggestChannelsStatsType } from '../pages/SettingsDashboard';
type BiggestChannelsStatsProps = {
biggestChannelsStatsByCount?: BiggestChannelsStatsType;
biggestChannelsStatsByDuration?: BiggestChannelsStatsType;
biggestChannelsStatsByMediaSize?: BiggestChannelsStatsType;
useSI: boolean;
};
const BiggestChannelsStats = ({
biggestChannelsStatsByCount,
biggestChannelsStatsByDuration,
biggestChannelsStatsByMediaSize,
useSI,
}: BiggestChannelsStatsProps) => {
if (
!biggestChannelsStatsByCount &&
!biggestChannelsStatsByDuration &&
!biggestChannelsStatsByMediaSize
) {
return <p id="loading">Loading...</p>;
}
return (
<>
<div className="info-box-item">
<table className="agg-channel-table">
<thead>
<tr>
<th>Name</th>
<th className="agg-channel-right-align">Videos</th>
</tr>
</thead>
<tbody>
{biggestChannelsStatsByCount &&
biggestChannelsStatsByCount.map(({ id, name, doc_count }) => {
return (
<tr key={id}>
<td className="agg-channel-name">
<Link to={Routes.Channel(id)}>{name}</Link>
</td>
<td className="agg-channel-right-align">{formatNumbers(doc_count)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="info-box-item">
<table className="agg-channel-table">
<thead>
<tr>
<th>Name</th>
<th className="agg-channel-right-align">Duration</th>
</tr>
</thead>
<tbody>
{biggestChannelsStatsByDuration &&
biggestChannelsStatsByDuration.map(({ id, name, duration_str }) => {
return (
<tr key={id}>
<td className="agg-channel-name">
<Link to={Routes.Channel(id)}>{name}</Link>
</td>
<td className="agg-channel-right-align">{duration_str}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="info-box-item">
<table className="agg-channel-table">
<thead>
<tr>
<th>Name</th>
<th className="agg-channel-right-align">Media Size</th>
</tr>
</thead>
<tbody>
{biggestChannelsStatsByMediaSize &&
biggestChannelsStatsByMediaSize.map(({ id, name, media_size }) => {
return (
<tr key={id}>
<td className="agg-channel-name">
<Link to={Routes.Channel(id)}>{name}</Link>
</td>
<td className="agg-channel-right-align">{humanFileSize(media_size, useSI)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
);
};
export default BiggestChannelsStats;

View File

@@ -0,0 +1,40 @@
export interface ButtonProps {
id?: string;
name?: string;
className?: string;
type?: 'submit' | 'reset' | 'button' | undefined;
label?: string | JSX.Element | JSX.Element[];
children?: string | JSX.Element | JSX.Element[];
value?: string;
title?: string;
onClick?: () => void;
}
const Button = ({
id,
name,
className,
type,
label,
children,
value,
title,
onClick,
}: ButtonProps) => {
return (
<button
id={id}
name={name}
className={className}
type={type}
value={value}
title={title}
onClick={onClick}
>
{label}
{children}
</button>
);
};
export default Button;

View File

@@ -0,0 +1,21 @@
import getApiUrl from '../configuration/getApiUrl';
import defaultChannelImage from '/img/default-channel-banner.jpg';
type ChannelIconProps = {
channel_id: string;
};
const ChannelBanner = ({ channel_id }: ChannelIconProps) => {
return (
<img
src={`${getApiUrl()}/cache/channels/${channel_id}_banner.jpg`}
alt={`${channel_id}-banner`}
onError={({ currentTarget }) => {
currentTarget.onerror = null; // prevents looping
currentTarget.src = defaultChannelImage;
}}
/>
);
};
export default ChannelBanner;

View File

@@ -0,0 +1,21 @@
import getApiUrl from '../configuration/getApiUrl';
import defaultChannelIcon from '/img/default-channel-icon.jpg';
type ChannelIconProps = {
channel_id: string;
};
const ChannelIcon = ({ channel_id }: ChannelIconProps) => {
return (
<img
src={`${getApiUrl()}/cache/channels/${channel_id}_thumb.jpg`}
alt="channel-thumb"
onError={({ currentTarget }) => {
currentTarget.onerror = null; // prevents looping
currentTarget.src = defaultChannelIcon;
}}
/>
);
};
export default ChannelIcon;

View File

@@ -0,0 +1,83 @@
import { Link } from 'react-router-dom';
import { ChannelType } from '../pages/Channels';
import { ViewLayoutType } from '../pages/Home';
import Routes from '../configuration/routes/RouteList';
import updateChannelSubscription from '../api/actions/updateChannelSubscription';
import formatDate from '../functions/formatDates';
import FormattedNumber from './FormattedNumber';
import Button from './Button';
import ChannelIcon from './ChannelIcon';
import ChannelBanner from './ChannelBanner';
type ChannelListProps = {
channelList: ChannelType[] | undefined;
viewLayout: ViewLayoutType;
refreshChannelList: (refresh: boolean) => void;
};
const ChannelList = ({ channelList, viewLayout, refreshChannelList }: ChannelListProps) => {
if (!channelList || channelList.length === 0) {
return <p>No channels found.</p>;
}
return (
<>
{channelList.map(channel => {
return (
<div key={channel.channel_id} className={`channel-item ${viewLayout}`}>
<div className={`channel-banner ${viewLayout}`}>
<Link to={Routes.Channel(channel.channel_id)}>
<ChannelBanner channel_id={channel.channel_id} />
</Link>
</div>
<div className={`info-box info-box-2 ${viewLayout}`}>
<div className="info-box-item">
<div className="round-img">
<Link to={Routes.Channel(channel.channel_id)}>
<ChannelIcon channel_id={channel.channel_id} />
</Link>
</div>
<div>
<h3>
<Link to={Routes.Channel(channel.channel_id)}>{channel.channel_name}</Link>
</h3>
<FormattedNumber text="Subscribers:" number={channel.channel_subs} />
</div>
</div>
<div className="info-box-item">
<div>
<p>Last refreshed: {formatDate(channel.channel_last_refresh)}</p>
{channel.channel_subscribed && (
<Button
label="Unsubscribe"
className="unsubscribe"
type="button"
title={`Unsubscribe from ${channel.channel_name}`}
onClick={async () => {
await updateChannelSubscription(channel.channel_id, false);
refreshChannelList(true);
}}
/>
)}
{!channel.channel_subscribed && (
<Button
label="Subscribe"
type="button"
title={`Subscribe to ${channel.channel_name}`}
onClick={async () => {
await updateChannelSubscription(channel.channel_id, true);
refreshChannelList(true);
}}
/>
)}
</div>
</div>
</div>
</div>
);
})}
</>
);
};
export default ChannelList;

View File

@@ -0,0 +1,76 @@
import { Link } from 'react-router-dom';
import Routes from '../configuration/routes/RouteList';
import updateChannelSubscription from '../api/actions/updateChannelSubscription';
import FormattedNumber from './FormattedNumber';
import Button from './Button';
import ChannelIcon from './ChannelIcon';
type ChannelOverviewProps = {
channelId: string;
channelname: string;
channelSubs: number;
channelSubscribed: boolean;
showSubscribeButton?: boolean;
isUserAdmin?: boolean;
setRefresh: (status: boolean) => void;
};
const ChannelOverview = ({
channelId,
channelSubs,
channelSubscribed,
channelname,
showSubscribeButton = false,
isUserAdmin,
setRefresh,
}: ChannelOverviewProps) => {
return (
<>
<div className="info-box-item">
<div className="round-img">
<Link to={Routes.Channel(channelId)}>
<ChannelIcon channel_id={channelId} />
</Link>
</div>
<div>
<h3>
<Link to={Routes.ChannelVideo(channelId)}>{channelname}</Link>
</h3>
<FormattedNumber text="Subscribers:" number={channelSubs} />
{showSubscribeButton && (
<>
{channelSubscribed && isUserAdmin && (
<Button
label="Unsubscribe"
className="unsubscribe"
type="button"
title={`Unsubscribe from ${channelname}`}
onClick={async () => {
await updateChannelSubscription(channelId, false);
setRefresh(true);
}}
/>
)}
{!channelSubscribed && (
<Button
label="Subscribe"
type="button"
title={`Subscribe to ${channelname}`}
onClick={async () => {
await updateChannelSubscription(channelId, true);
setRefresh(true);
}}
/>
)}
</>
)}
</div>
</div>
</>
);
};
export default ChannelOverview;

View File

@@ -0,0 +1,106 @@
import iconThumb from '/img/icon-thumb.svg';
import iconHeart from '/img/icon-heart.svg';
import formatDate from '../functions/formatDates';
import { Fragment, useState } from 'react';
import Linkify from './Linkify';
import formatNumbers from '../functions/formatNumbers';
import Button from './Button';
export type CommentReplyType = {
comment_id: string;
comment_text: string;
comment_timestamp: number;
comment_time_text: string;
comment_likecount: number;
comment_is_favorited: false;
comment_author: string;
comment_author_id: string;
comment_author_thumbnail: string;
comment_author_is_uploader: boolean;
comment_parent: string;
};
export type CommentsType = {
comment_id: string;
comment_text: string;
comment_timestamp: number;
comment_time_text: string;
comment_likecount: number;
comment_is_favorited: boolean;
comment_author: string;
comment_author_id: string;
comment_author_thumbnail: string;
comment_author_is_uploader: boolean;
comment_parent: string;
comment_replies?: CommentReplyType[];
};
type CommentBoxProps = {
comment: CommentsType;
};
const CommentBox = ({ comment }: CommentBoxProps) => {
const [showSubComments, setShowSubComments] = useState(false);
const hasSubComments =
comment.comment_replies !== undefined && comment.comment_replies.length > 0;
return (
<div className="comment-box">
<h3 className={comment.comment_author_is_uploader ? 'comment-highlight' : ''}>
{comment.comment_author}
</h3>
<p>
<Linkify>{comment.comment_text}</Linkify>
</p>
<div className="comment-meta">
<span>{formatDate(comment.comment_timestamp * 1000)}</span>
<span className="space-carrot">|</span>
<span className="thumb-icon">
<img src={iconThumb} />{' '}
{formatNumbers(comment.comment_likecount, { notation: 'compact' })}
</span>
{comment.comment_is_favorited && (
<>
<span className="space-carrot">|</span>
<span className="comment-like">
<img src={iconHeart} />
</span>
</>
)}
</div>
{hasSubComments && (
<>
<Button
onClick={() => {
setShowSubComments(!showSubComments);
}}
>
<>
<span id="toggle-icon">{showSubComments ? '▲' : '▼'}</span>{' '}
{comment.comment_replies?.length} replies
</>
</Button>
<div className="comments-replies" style={{ display: 'block' }}>
{showSubComments &&
comment.comment_replies?.map(comment => {
return (
<Fragment key={comment.comment_id}>
<CommentBox comment={comment} />
</Fragment>
);
})}
</div>
</>
)}
</div>
);
};
export default CommentBox;

View File

@@ -0,0 +1,41 @@
import humanFileSize from '../functions/humanFileSize';
import formatDate from '../functions/formatDates';
import formatNumbers from '../functions/formatNumbers';
import { DownloadHistoryStatsType } from '../pages/SettingsDashboard';
type DownloadHistoryStatsProps = {
downloadHistoryStats?: DownloadHistoryStatsType;
useSI: boolean;
};
const DownloadHistoryStats = ({ downloadHistoryStats, useSI }: DownloadHistoryStatsProps) => {
if (!downloadHistoryStats) {
return <p id="loading">Loading...</p>;
}
if (downloadHistoryStats.length === 0) {
return (
<div className="info-box-item">
<h3>No recent downloads</h3>
</div>
);
}
return downloadHistoryStats.map(({ date, count, media_size }) => {
const videoText = count === 1 ? 'Video' : 'Videos';
const intlDate = formatDate(date);
return (
<div key={date} className="info-box-item">
<h3>{intlDate}</h3>
<p>
+{formatNumbers(count)} {videoText}
<br />
{humanFileSize(media_size, useSI)}
</p>
</div>
);
});
};
export default DownloadHistoryStats;

View File

@@ -0,0 +1,108 @@
import { Link } from 'react-router-dom';
import Download from '../pages/Download';
import Routes from '../configuration/routes/RouteList';
import formatDate from '../functions/formatDates';
import Button from './Button';
import deleteDownloadById from '../api/actions/deleteDownloadById';
import updateDownloadQueueStatusById from '../api/actions/updateDownloadQueueStatusById';
import { useState } from 'react';
import getApiUrl from '../configuration/getApiUrl';
type DownloadListItemProps = {
view: string;
download: Download;
showIgnored: boolean;
setRefresh: (status: boolean) => void;
};
const DownloadListItem = ({ view, download, showIgnored, setRefresh }: DownloadListItemProps) => {
const [hideDownload, setHideDownload] = useState(false);
return (
<div className={`video-item ${view}`} id={`dl-${download.youtube_id}`}>
<div className={`video-thumb-wrap ${view}`}>
<div className="video-thumb">
<img src={`${getApiUrl()}${download.vid_thumb_url}`} alt="video_thumb" />
<div className="video-tags">
{showIgnored && <span>ignored</span>}
{!showIgnored && <span>queued</span>}
<span>{download.vid_type}</span>
{download.auto_start && <span>auto</span>}
</div>
</div>
</div>
<div className={`video-desc ${view}`}>
<div>
{download.channel_indexed && (
<Link to={Routes.Channel(download.channel_id)}>{download.channel_name}</Link>
)}
{!download.channel_indexed && <span>{download.channel_name}</span>}
<a href={`https://www.youtube.com/watch?v=${download.youtube_id}`} target="_blank">
<h3>{download.title}</h3>
</a>
</div>
<p>
Published: {formatDate(download.published)} | Duration: {download.duration} |{' '}
{download.youtube_id}
</p>
{download.message && <p className="danger-zone">{download.message}</p>}
<div>
{showIgnored && (
<>
<Button
label="Forget"
onClick={async () => {
await deleteDownloadById(download.youtube_id);
setRefresh(true);
}}
/>{' '}
<Button
label="Add to queue"
onClick={async () => {
await updateDownloadQueueStatusById(download.youtube_id, 'pending');
setRefresh(true);
}}
/>
</>
)}
{!showIgnored && (
<>
<Button
label="Ignore"
onClick={async () => {
await updateDownloadQueueStatusById(download.youtube_id, 'ignore');
setRefresh(true);
}}
/>{' '}
{!hideDownload && (
<Button
label="Download now"
onClick={async () => {
setHideDownload(true);
await updateDownloadQueueStatusById(download.youtube_id, 'priority');
setRefresh(true);
}}
/>
)}
</>
)}
{download.message && (
<Button
label="Delete"
className="danger-button"
onClick={async () => {
await deleteDownloadById(download.youtube_id);
setRefresh(true);
}}
/>
)}
</div>
</div>
</div>
);
};
export default DownloadListItem;

View File

@@ -0,0 +1,178 @@
import { useEffect, useState } from 'react';
import { SponsorBlockType, VideoResponseType } from '../pages/Video';
import VideoPlayer, { VideoProgressType } from './VideoPlayer';
import loadVideoById from '../api/loader/loadVideoById';
import loadVideoProgressById from '../api/loader/loadVideoProgressById';
import loadSponsorblockByVideoId from '../api/loader/loadSponsorblockByVideoId';
import iconClose from '/img/icon-close.svg';
import iconEye from '/img/icon-eye.svg';
import iconThumb from '/img/icon-thumb.svg';
import WatchedCheckBox from './WatchedCheckBox';
import GoogleCast from './GoogleCast';
import updateWatchedState from '../api/actions/updateWatchedState';
import formatNumbers from '../functions/formatNumbers';
import { Link, useSearchParams } from 'react-router-dom';
import Routes from '../configuration/routes/RouteList';
import loadPlaylistById from '../api/loader/loadPlaylistById';
type Playlist = {
id: string;
name: string;
};
type PlaylistList = Playlist[];
type EmbeddableVideoPlayerProps = {
videoId: string;
};
const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => {
const [, setSearchParams] = useSearchParams();
const [refresh, setRefresh] = useState(false);
const [videoResponse, setVideoResponse] = useState<VideoResponseType>();
const [videoProgress, setVideoProgress] = useState<VideoProgressType>();
const [playlists, setPlaylists] = useState<PlaylistList>();
const [sponsorblockResponse, setSponsorblockResponse] = useState<SponsorBlockType>();
useEffect(() => {
(async () => {
const videoResponse = await loadVideoById(videoId);
const videoProgress = await loadVideoProgressById(videoId);
const sponsorblockReponse = await loadSponsorblockByVideoId(videoId);
const playlistIds = videoResponse.data.playlist;
if (playlistIds !== undefined) {
const playlists = await Promise.all(
playlistIds.map(async (playlistid: string) => {
const playlistResponse = await loadPlaylistById(playlistid);
return playlistResponse.data;
}),
);
const playlistsFiltered = playlists
.filter(playlist => {
return playlist.playlist_subscribed;
})
.map(playlist => {
return {
id: playlist.playlist_id,
name: playlist.playlist_name,
};
});
setPlaylists(playlistsFiltered);
}
setVideoResponse(videoResponse);
setVideoProgress(videoProgress);
setSponsorblockResponse(sponsorblockReponse);
setRefresh(false);
})();
}, [videoId, refresh]);
if (videoResponse === undefined) {
return [];
}
const video = videoResponse.data;
const name = video.title;
const channelId = video.channel.channel_id;
const channelName = video.channel.channel_name;
const watched = video.player.watched;
const views = formatNumbers(video.stats.view_count);
const hasLikes = video.stats.like_count;
const likes = formatNumbers(video.stats.like_count);
const hasDislikes = video.stats.dislike_count > 0 && videoResponse.config.downloads.integrate_ryd;
const dislikes = formatNumbers(video.stats.dislike_count);
const config = videoResponse.config;
const cast = config.enable_cast;
return (
<>
<div className="player-wrapper">
<div className="video-player">
<VideoPlayer
video={videoResponse}
videoProgress={videoProgress}
sponsorBlock={sponsorblockResponse}
embed={true}
/>
<div className="player-title boxed-content">
<img
className="close-button"
src={iconClose}
alt="close-icon"
title="Close player"
onClick={() => {
setSearchParams({});
}}
/>
<WatchedCheckBox
watched={watched}
onClick={async status => {
await updateWatchedState({
id: videoId,
is_watched: status,
});
setRefresh(true);
}}
/>
{cast && (
<GoogleCast
video={video}
videoProgress={videoProgress}
setRefresh={() => {
setRefresh(true);
}}
/>
)}
<div className="thumb-icon player-stats">
<img src={iconEye} alt="views icon" />
<span>{views}</span>
{hasLikes && (
<>
<span>|</span>
<img src={iconThumb} alt="thumbs-up" />
<span>{likes}</span>
</>
)}
{hasDislikes && (
<>
<span>|</span>
<img className="dislike" src={iconThumb} alt="thumbs-down" />
<span>{dislikes}</span>
</>
)}
</div>
<div className="player-channel-playlist">
<h3>
<Link to={Routes.Channel(channelId)}>{channelName}</Link>
</h3>
{playlists?.map(({ id, name }) => {
return (
<h5 key={id}>
<Link to={Routes.Playlist(id)}>{name}</Link>
</h5>
);
})}
</div>
<Link to={Routes.Video(videoId)}>
<h2 id="video-title">{name}</h2>
</Link>
</div>
</div>
</div>
</>
);
};
export default EmbeddableVideoPlayer;

View File

@@ -0,0 +1,186 @@
import { useEffect } from 'react';
import iconSort from '/img/icon-sort.svg';
import iconAdd from '/img/icon-add.svg';
import iconSubstract from '/img/icon-substract.svg';
import iconGridView from '/img/icon-gridview.svg';
import iconListView from '/img/icon-listview.svg';
import { SortByType, SortOrderType, ViewLayoutType } from '../pages/Home';
import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig';
type FilterbarProps = {
hideToggleText: string;
showHidden?: boolean;
hideWatched?: boolean;
isGridView?: boolean;
view: ViewLayoutType;
viewStyleName: string;
gridItems: number;
sortBy?: SortByType;
sortOrder?: SortOrderType;
userMeConfig: UserConfigType;
setShowHidden?: (showHidden: boolean) => void;
setHideWatched?: (hideWatched: boolean) => void;
setView: (view: ViewLayoutType) => void;
setSortBy?: (sortBy: SortByType) => void;
setSortOrder?: (sortOrder: SortOrderType) => void;
setGridItems: (gridItems: number) => void;
setRefresh?: (status: boolean) => void;
};
const Filterbar = ({
hideToggleText,
showHidden,
hideWatched,
isGridView,
view,
viewStyleName,
gridItems,
sortBy,
sortOrder,
userMeConfig,
setShowHidden,
setHideWatched,
setView,
setSortBy,
setSortOrder,
setGridItems,
setRefresh,
}: FilterbarProps) => {
useEffect(() => {
(async () => {
if (
userMeConfig.hide_watched !== hideWatched ||
userMeConfig[viewStyleName.toString() as keyof typeof userMeConfig] !== view ||
userMeConfig.grid_items !== gridItems ||
userMeConfig.sort_by !== sortBy ||
userMeConfig.sort_order !== sortOrder
) {
const userConfig: UserConfigType = {
hide_watched: hideWatched,
[viewStyleName.toString()]: view,
grid_items: gridItems,
sort_by: sortBy,
sort_order: sortOrder,
};
await updateUserConfig(userConfig);
setRefresh?.(true);
}
})();
}, [hideWatched, view, gridItems, sortBy, sortOrder, viewStyleName, setRefresh, userMeConfig]);
return (
<div className="view-controls three">
<div className="toggle">
<span>{hideToggleText}</span>
<div className="toggleBox">
<input
id="hide_watched"
type="checkbox"
checked={hideWatched}
onChange={() => {
setHideWatched?.(!hideWatched);
}}
/>
{!hideWatched && (
<label htmlFor="" className="ofbtn">
Off
</label>
)}
{hideWatched && (
<label htmlFor="" className="onbtn">
On
</label>
)}
</div>
</div>
{showHidden && (
<div className="sort">
<div id="form">
<span>Sort by:</span>
<select
name="sort_by"
id="sort"
value={sortBy}
onChange={event => {
setSortBy?.(event.target.value as SortByType);
}}
>
<option value="published">date published</option>
<option value="downloaded">date downloaded</option>
<option value="views">views</option>
<option value="likes">likes</option>
<option value="duration">duration</option>
<option value="filesize">file size</option>
</select>
<select
name="sort_order"
id="sort-order"
value={sortOrder}
onChange={event => {
setSortOrder?.(event.target.value as SortOrderType);
}}
>
<option value="asc">asc</option>
<option value="desc">desc</option>
</select>
</div>
</div>
)}
<div className="view-icons">
{setShowHidden && (
<img
src={iconSort}
alt="sort-icon"
onClick={() => {
setShowHidden?.(!showHidden);
}}
id="animate-icon"
/>
)}
{isGridView && (
<div className="grid-count">
{gridItems < 7 && (
<img
src={iconAdd}
onClick={() => {
setGridItems(gridItems + 1);
}}
alt="grid plus row"
/>
)}
{gridItems > 3 && (
<img
src={iconSubstract}
onClick={() => {
setGridItems(gridItems - 1);
}}
alt="grid minus row"
/>
)}
</div>
)}
<img
src={iconGridView}
onClick={() => {
setView('grid');
}}
alt="grid view"
/>
<img
src={iconListView}
onClick={() => {
setView('list');
}}
alt="list view"
/>
</div>
</div>
);
};
export default Filterbar;

View File

@@ -0,0 +1,60 @@
import { Link } from 'react-router-dom';
import Routes from '../configuration/routes/RouteList';
export type TaUpdateType = {
version?: string;
is_breaking?: boolean;
};
interface Props {
version: string;
taUpdate?: TaUpdateType;
}
const Footer = ({ version, taUpdate }: Props) => {
const currentYear = new Date().getFullYear();
return (
<div className="footer">
<div className="boxed-content">
<span>© 2021 - {currentYear} </span>
<span>TubeArchivist </span>
<span>{version} </span>
{taUpdate?.version && (
<>
<span className="danger-zone">
{taUpdate.version} available
{taUpdate.is_breaking && <span className="danger-zone">Breaking Changes!</span>}
</span>{' '}
<span>
<a
href={`https://github.com/tubearchivist/tubearchivist/releases/tag/${taUpdate.version}`}
target="_blank"
>
Release Page
</a>{' '}
|{' '}
</span>
</>
)}
<span>
<Link to={Routes.About}>About</Link> |{' '}
<a href="https://github.com/tubearchivist/tubearchivist" target="_blank">
GitHub
</a>{' '}
|{' '}
<a href="https://hub.docker.com/r/bbilly1/tubearchivist" target="_blank">
Docker Hub
</a>{' '}
|{' '}
<a href="https://www.tubearchivist.com/discord" target="_blank">
Discord
</a>{' '}
| <a href="https://www.reddit.com/r/TubeArchivist/">Reddit</a>
</span>
</div>
</div>
);
};
export default Footer;

View File

@@ -0,0 +1,27 @@
import formatNumbers from '../functions/formatNumbers';
type FormattedNumberProps = {
text: string;
number: number;
};
const FormattedNumber = ({ text, number }: FormattedNumberProps) => {
let options = {};
if (number >= 1000000) {
options = {
notation: 'compact',
compactDisplay: 'long',
};
}
return (
<>
<p>
{text} {formatNumbers(number, options)}
</p>
</>
);
};
export default FormattedNumber;

View File

@@ -0,0 +1,230 @@
import { useCallback, useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import { VideoType } from '../pages/Home';
import updateWatchedState from '../api/actions/updateWatchedState';
import updateVideoProgressById from '../api/actions/updateVideoProgressById';
import watchedThreshold from '../functions/watchedThreshold';
import { VideoProgressType } from './VideoPlayer';
const getURL = () => {
return window.location.origin;
};
function shiftCurrentTime(contentCurrentTime: number | undefined) {
console.log(contentCurrentTime);
if (contentCurrentTime === undefined) {
return 0;
}
// Shift media back 3 seconds to prevent missing some of the content
if (contentCurrentTime > 5) {
return contentCurrentTime - 3;
} else {
return 0;
}
}
async function castVideoProgress(
player: {
mediaInfo: { contentId: string | string[] };
currentTime: number;
duration: number;
},
video: VideoType | undefined,
) {
if (!video) {
console.log('castVideoProgress: Video to cast not found...');
return;
}
const videoId = video.youtube_id;
if (player.mediaInfo.contentId.includes(videoId)) {
const currentTime = player.currentTime;
const duration = player.duration;
if (currentTime % 10 <= 1.0 && currentTime !== 0 && duration !== 0) {
// Check progress every 10 seconds or else progress is checked a few times a second
await updateVideoProgressById({
youtubeId: videoId,
currentProgress: currentTime,
});
if (!video.player.watched) {
// Check if video is already marked as watched
if (watchedThreshold(currentTime, duration)) {
await updateWatchedState({
id: videoId,
is_watched: true,
});
}
}
}
}
}
async function castVideoPaused(
player: {
currentTime: number;
duration: number;
mediaInfo: { contentId: string | string[] } | null;
},
video: VideoType | undefined,
) {
if (!video) {
console.log('castVideoPaused: Video to cast not found...');
return;
}
const videoId = video?.youtube_id;
const currentTime = player.currentTime;
const duration = player.duration;
if (player.mediaInfo != null) {
if (player.mediaInfo.contentId.includes(videoId)) {
if (currentTime !== 0 && duration !== 0) {
await updateVideoProgressById({
youtubeId: videoId,
currentProgress: currentTime,
});
}
}
}
}
type GoogleCastProps = {
video?: VideoType;
videoProgress?: VideoProgressType;
setRefresh?: () => void;
};
const GoogleCast = ({ video, videoProgress, setRefresh }: GoogleCastProps) => {
const [isConnected, setIsConnected] = useState(false);
const setup = useCallback(() => {
const cast = globalThis.cast;
const chrome = globalThis.chrome;
cast.framework.CastContext.getInstance().setOptions({
receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, // Use built in receiver app on cast device, see https://developers.google.com/cast/docs/styled_receiver if you want to be able to add a theme, splash screen or watermark. Has a $5 one time fee.
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
});
const player = new cast.framework.RemotePlayer();
const playerController = new cast.framework.RemotePlayerController(player);
// Add event listerner to check if a connection to a cast device is initiated
playerController.addEventListener(
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
function () {
setIsConnected(player.isConnected);
},
);
playerController.addEventListener(
cast.framework.RemotePlayerEventType.CURRENT_TIME_CHANGED,
function () {
castVideoProgress(player, video);
},
);
playerController.addEventListener(
cast.framework.RemotePlayerEventType.IS_PAUSED_CHANGED,
function () {
castVideoPaused(player, video);
setRefresh?.();
},
);
}, [setRefresh, video]);
const startPlayback = useCallback(() => {
const chrome = globalThis.chrome;
const cast = globalThis.cast;
const castSession = cast.framework.CastContext.getInstance().getCurrentSession();
const mediaUrl = video?.media_url;
const vidThumbUrl = video?.vid_thumb_url;
const contentTitle = video?.title;
const contentId = `${getURL()}${mediaUrl}`;
const contentImage = `${getURL()}${vidThumbUrl}`;
const contentType = 'video/mp4'; // Set content type, only videos right now so it is hard coded
const contentSubtitles = [];
const videoSubtitles = video?.subtitles; // Array of subtitles
if (typeof videoSubtitles !== 'undefined') {
for (let i = 0; i < videoSubtitles.length; i++) {
const subtitle = new chrome.cast.media.Track(i, chrome.cast.media.TrackType.TEXT);
subtitle.trackContentId = videoSubtitles[i].media_url;
subtitle.trackContentType = 'text/vtt';
subtitle.subtype = chrome.cast.media.TextTrackType.SUBTITLES;
subtitle.name = videoSubtitles[i].name;
subtitle.language = videoSubtitles[i].lang;
subtitle.customData = null;
contentSubtitles.push(subtitle);
}
}
const mediaInfo = new chrome.cast.media.MediaInfo(contentId, contentType); // Create MediaInfo var that contains url and content type
// mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED; // Set type of stream, BUFFERED, LIVE, OTHER
mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata(); // Create metadata var and add it to MediaInfo
mediaInfo.metadata.title = contentTitle?.replace('&amp;', '&'); // Set the video title
mediaInfo.metadata.images = [new chrome.cast.Image(contentImage)]; // Set the video thumbnail
// mediaInfo.textTrackStyle = new chrome.cast.media.TextTrackStyle();
mediaInfo.tracks = contentSubtitles;
const request = new chrome.cast.media.LoadRequest(mediaInfo); // Create request with the previously set MediaInfo.
// request.queueData = new chrome.cast.media.QueueData(); // See https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media.QueueData for playlist support.
request.currentTime = shiftCurrentTime(videoProgress?.position); // Set video start position based on the browser video position
// request.activeTrackIds = contentActiveSubtitle; // Set active subtitle based on video player
castSession.loadMedia(request).then(
function () {
console.log('media loaded');
},
function (error: { code: string }) {
console.log('Error', error, 'Error code: ' + error.code);
},
); // Send request to cast device
// Do not add videoProgress?.position, this will cause loops!
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [video?.media_url, video?.subtitles, video?.title, video?.vid_thumb_url]);
useEffect(() => {
// @ts-expect-error __onGCastApiAvailable is the google cast window hook ( source: https://developers.google.com/cast/docs/web_sender/integrate )
window['__onGCastApiAvailable'] = function (isAvailable: boolean) {
if (isAvailable) {
setup();
}
};
}, [setup]);
useEffect(() => {
console.log('isConnected', isConnected);
if (isConnected) {
startPlayback();
}
}, [isConnected, startPlayback]);
if (!video) {
return <p>Video for cast not found...</p>;
}
return (
<>
<>
<Helmet>
<script
type="text/javascript"
src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
></script>
</Helmet>
{/* @ts-expect-error React does not know what to do with the google-cast-launcher, but it works. */}
<google-cast-launcher id="castbutton"></google-cast-launcher>
</>
</>
);
};
export default GoogleCast;

View File

@@ -0,0 +1,34 @@
import DOMPurify from 'dompurify';
type LinkifyProps = {
children: string;
ignoreLineBreak?: boolean;
};
// source: https://www.js-craft.io/blog/react-detect-url-text-convert-link/
const Linkify = ({ children, ignoreLineBreak = false }: LinkifyProps) => {
const isUrl = (word: string) => {
const urlPattern = /(https?:\/\/[^\s]+)/g;
return word.match(urlPattern);
};
const addMarkup = (word: string) => {
return isUrl(word) ? `<a href="${word}">${word}</a>` : word;
};
let workingText = children;
if (!ignoreLineBreak) {
workingText = workingText.replaceAll('\n', ' <br/> ');
}
const words = workingText.split(' ');
const formatedWords = words.map(w => addMarkup(w));
const html = DOMPurify.sanitize(formatedWords.join(' '));
return <span dangerouslySetInnerHTML={{ __html: html }} />;
};
export default Linkify;

View File

@@ -0,0 +1,94 @@
import iconClose from '/img/icon-close.svg';
import iconArrowTop from '/img/icon-arrow-top.svg';
import iconArrowUp from '/img/icon-arrow-up.svg';
import iconArrowDown from '/img/icon-arrow-down.svg';
import iconArrowBottom from '/img/icon-arrow-bottom.svg';
import iconRemove from '/img/icon-remove.svg';
import updateCustomPlaylist from '../api/actions/updateCustomPlaylist';
type MoveVideoMenuProps = {
playlistId?: string;
videoId: string;
setCloseMenu: (status: boolean) => void;
setRefresh: (status: boolean) => void;
};
const MoveVideoMenu = ({ playlistId, videoId, setCloseMenu, setRefresh }: MoveVideoMenuProps) => {
if (playlistId === undefined) {
return [];
}
return (
<>
<div className="video-popup-menu">
<img
src={iconClose}
className="video-popup-menu-close-button"
title="Close menu"
onClick={() => setCloseMenu(true)}
/>
<h3>Move Video</h3>
<img
className="move-video-button"
data-context="top"
onClick={async () => {
await updateCustomPlaylist('top', playlistId, videoId);
setRefresh(true);
}}
src={iconArrowTop}
title="Move to top"
/>
<img
className="move-video-button"
data-context="up"
onClick={async () => {
await updateCustomPlaylist('up', playlistId, videoId);
setRefresh(true);
}}
src={iconArrowUp}
title="Move up"
/>
<img
className="move-video-button"
data-context="down"
onClick={async () => {
await updateCustomPlaylist('down', playlistId, videoId);
setRefresh(true);
}}
src={iconArrowDown}
title="Move down"
/>
<img
className="move-video-button"
data-context="bottom"
onClick={async () => {
await updateCustomPlaylist('bottom', playlistId, videoId);
setRefresh(true);
}}
src={iconArrowBottom}
title="Move to bottom"
/>
<img
className="move-video-button"
data-context="remove"
onClick={async () => {
await updateCustomPlaylist('remove', playlistId, videoId);
setRefresh(true);
}}
src={iconRemove}
title="Remove from playlist"
/>
</div>
</>
);
};
export default MoveVideoMenu;

View File

@@ -0,0 +1,42 @@
import { Link } from 'react-router-dom';
import iconSearch from '/img/icon-search.svg';
import iconGear from '/img/icon-gear.svg';
import iconExit from '/img/icon-exit.svg';
import Routes from '../configuration/routes/RouteList';
import NavigationItem from './NavigationItem';
interface NavigationProps {
isAdmin: boolean;
}
const Navigation = ({ isAdmin }: NavigationProps) => {
return (
<div className="boxed-content">
<Link to={Routes.Home}>
<div className="top-banner"></div>
</Link>
<div className="top-nav">
<div className="nav-items">
<NavigationItem label="home" navigateTo={Routes.Home} />
<NavigationItem label="channels" navigateTo={Routes.Channels} />
<NavigationItem label="playlists" navigateTo={Routes.Playlists} />
{isAdmin && <NavigationItem label="downloads" navigateTo={Routes.Downloads} />}
</div>
<div className="nav-icons">
<Link to={Routes.Search}>
<img src={iconSearch} alt="search-icon" title="Search" />
</Link>
<Link to={Routes.SettingsDashboard}>
<img src={iconGear} alt="gear-icon" title="Settings" />
</Link>
<Link to={Routes.Logout}>
<img className="alert-hover" src={iconExit} alt="exit-icon" title="Logout" />
</Link>
</div>
</div>
</div>
);
};
export default Navigation;

View File

@@ -0,0 +1,16 @@
import { Link } from 'react-router-dom';
interface NavigationItemProps {
navigateTo: string;
label: string;
}
const NavigationItem = ({ label, navigateTo }: NavigationItemProps) => {
return (
<Link to={navigateTo}>
<div className="nav-item">{label}</div>
</Link>
);
};
export default NavigationItem;

View File

@@ -0,0 +1,101 @@
import { Fragment, useEffect, useState } from 'react';
import loadNotifications, { NotificationPages } from '../api/loader/loadNotifications';
import iconStop from '/img/icon-stop.svg';
import stopTaskByName from '../api/actions/stopTaskByName';
type NotificationType = {
title: string;
group: string;
api_stop: boolean;
level: string;
id: string;
command: boolean | string;
messages: string[];
progress: number;
};
type NotificationResponseType = NotificationType[];
type NotificationsProps = {
pageName: NotificationPages;
includeReindex?: boolean;
update?: boolean;
setShouldRefresh?: (isDone: boolean) => void;
};
const Notifications = ({
pageName,
includeReindex = false,
update,
setShouldRefresh,
}: NotificationsProps) => {
const [notificationResponse, setNotificationResponse] = useState<NotificationResponseType>([]);
useEffect(() => {
const intervalId = setInterval(async () => {
const notifications = await loadNotifications(pageName, includeReindex);
if (notifications.length === 0) {
setNotificationResponse(notifications);
clearInterval(intervalId);
setShouldRefresh?.(true);
return;
} else {
setShouldRefresh?.(false);
}
setNotificationResponse(notifications);
}, 500);
return () => {
clearInterval(intervalId);
};
}, [pageName, update, setShouldRefresh, includeReindex]);
if (notificationResponse.length === 0) {
return [];
}
return (
<>
{notificationResponse.map(notification => (
<div
id={notification.id}
className={`notification ${notification.level}`}
key={notification.id}
>
<h3>{notification.title}</h3>
<p>
{notification.messages.map(message => {
return (
<Fragment key={message}>
{message}
<br />
</Fragment>
);
})}
</p>
<div className="task-control-icons">
{notification['api_stop'] && notification.command !== 'STOP' && (
<img
src={iconStop}
id="stop-icon"
title="Stop Task"
alt="stop icon"
onClick={async () => {
await stopTaskByName(notification.id);
}}
/>
)}
</div>
<div
className="notification-progress-bar"
style={{ width: `${notification.progress * 100 || 0}%` }}
></div>
</div>
))}
</>
);
};
export default Notifications;

View File

@@ -0,0 +1,53 @@
import { Fragment } from 'react';
import humanFileSize from '../functions/humanFileSize';
import StatsInfoBoxItem from './StatsInfoBoxItem';
import formatNumbers from '../functions/formatNumbers';
import { VideoStatsType } from '../pages/SettingsDashboard';
type OverviewStatsProps = {
videoStats?: VideoStatsType;
useSI: boolean;
};
const OverviewStats = ({ videoStats, useSI }: OverviewStatsProps) => {
if (!videoStats) {
return <p id="loading">Loading...</p>;
}
const cards = [
{
title: 'All: ',
data: {
Videos: formatNumbers(videoStats?.doc_count || 0),
['Media Size']: humanFileSize(videoStats?.media_size || 0, useSI),
Duration: videoStats?.duration_str,
},
},
{
title: 'Active: ',
data: {
Videos: formatNumbers(videoStats?.active_true?.doc_count || 0),
['Media Size']: humanFileSize(videoStats?.active_true?.media_size || 0, useSI),
Duration: videoStats?.active_true?.duration_str || 'NA',
},
},
{
title: 'Inactive: ',
data: {
Videos: formatNumbers(videoStats?.active_false?.doc_count || 0),
['Media Size']: humanFileSize(videoStats?.active_false?.media_size || 0, useSI),
Duration: videoStats?.active_false?.duration_str || 'NA',
},
},
];
return cards.map(card => {
return (
<Fragment key={card.title}>
<StatsInfoBoxItem title={card.title} card={card.data} />
</Fragment>
);
});
};
export default OverviewStats;

View File

@@ -0,0 +1,213 @@
import { Link } from 'react-router-dom';
import { Fragment } from 'react/jsx-runtime';
import Routes from '../configuration/routes/RouteList';
import { useCallback, useEffect } from 'react';
export type PaginationType = {
page_size?: number;
page_from?: number;
prev_pages?: false | number[];
current_page: number;
max_hits?: boolean;
params?: string;
last_page?: number;
next_pages?: [];
total_hits?: number;
};
interface Props {
pagination: PaginationType;
setPage: (page: number) => void;
}
const Pagination = ({ pagination, setPage }: Props) => {
const { total_hits, params, prev_pages, current_page, next_pages, last_page, max_hits } =
pagination;
const totalHits = Number(total_hits);
const currentPage = Number(current_page);
const hasMaxHits = Number(max_hits) > 0;
const lastPage = Number(last_page);
let hasParams = false;
if (params) {
hasParams = params.length > 0;
}
const handleKeyEvent = useCallback(
(event: KeyboardEvent) => {
const { code } = event;
if (code === 'ArrowRight') {
if (currentPage === 0 && totalHits > 1) {
setPage(2);
return;
}
if (currentPage > lastPage) {
return;
}
setPage(currentPage + 1);
}
if (code === 'ArrowLeft') {
if (currentPage === 0) {
return;
}
if (currentPage === 2) {
setPage(0);
return;
}
setPage(currentPage - 1);
}
},
[currentPage, lastPage, setPage, totalHits],
);
useEffect(() => {
window.addEventListener('keydown', handleKeyEvent);
return () => {
window.removeEventListener('keydown', handleKeyEvent);
};
}, [handleKeyEvent]);
return (
<div className="pagination">
<br />
{totalHits > 1 && (
<>
{currentPage > 1 && (
<>
<Link
to={`${Routes.Home}?${params}`}
className="pagination-item"
onClick={event => {
event.preventDefault();
setPage(0);
}}
>
First
</Link>{' '}
</>
)}
{prev_pages !== false &&
prev_pages &&
prev_pages.map((page: number) => {
if (hasParams) {
return (
<Fragment key={page}>
<Link
to={`${Routes.Home}?page=${page}&${params}`}
className="pagination-item"
onClick={event => {
event.preventDefault();
setPage(page);
}}
>
{page}
</Link>{' '}
</Fragment>
);
} else {
return (
<Fragment key={page}>
<Link
to={`${Routes.Home}?page=${page}`}
className="pagination-item"
onClick={event => {
event.preventDefault();
setPage(page);
}}
>
{page}
</Link>{' '}
</Fragment>
);
}
})}
{currentPage > 0 && <span>{`< Page ${currentPage} `}</span>}
{next_pages && next_pages.length > 0 && (
<>
<span>{'>'}</span>{' '}
{next_pages.map(page => {
if (hasParams) {
return (
<Fragment key={page}>
<a
className="pagination-item"
href={`?page=${page}&${params}`}
onClick={event => {
event.preventDefault();
setPage(page);
}}
>
{page}
</a>{' '}
</Fragment>
);
} else {
return (
<Fragment key={page}>
<a
className="pagination-item"
href={`?page=${page}`}
onClick={event => {
event.preventDefault();
setPage(page);
}}
>
{page}
</a>{' '}
</Fragment>
);
}
})}
</>
)}
{lastPage > 0 && (
<>
{hasParams && (
<a
className="pagination-item"
href={`?page=${lastPage}&${params}`}
onClick={event => {
event.preventDefault();
setPage(lastPage || 0);
}}
>
{hasMaxHits && `Max (${lastPage})`}
{!hasMaxHits && `Last (${lastPage})`}
</a>
)}
{!hasParams && (
<a
className="pagination-item"
href={`?page=${lastPage}`}
onClick={event => {
event.preventDefault();
setPage(lastPage || 0);
}}
>
{hasMaxHits && `Max (${lastPage})`}
{!hasMaxHits && `Last (${lastPage})`}
</a>
)}
</>
)}
</>
)}
</div>
);
};
export default Pagination;

View File

@@ -0,0 +1,9 @@
const PaginationDummy = () => {
return (
<div className="boxed-content">
<div className="pagination">{/** dummy pagination for padding */}</div>
</div>
);
};
export default PaginationDummy;

View File

@@ -0,0 +1,85 @@
import { Link } from 'react-router-dom';
import Routes from '../configuration/routes/RouteList';
import { ViewLayoutType } from '../pages/Home';
import { PlaylistType } from '../pages/Playlist';
import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription';
import formatDate from '../functions/formatDates';
import Button from './Button';
import getApiUrl from '../configuration/getApiUrl';
type PlaylistListProps = {
playlistList: PlaylistType[] | undefined;
viewLayout: ViewLayoutType;
setRefresh: (status: boolean) => void;
};
const PlaylistList = ({ playlistList, viewLayout, setRefresh }: PlaylistListProps) => {
if (!playlistList || playlistList.length === 0) {
return <p>No playlists found.</p>;
}
return (
<>
{playlistList.map((playlist: PlaylistType) => {
return (
<div key={playlist.playlist_id} className={`playlist-item ${viewLayout}`}>
<div className="playlist-thumbnail">
<Link to={Routes.Playlist(playlist.playlist_id)}>
<img
src={`${getApiUrl()}/cache/playlists/${playlist.playlist_id}.jpg`}
alt={`${playlist.playlist_id}-thumbnail`}
/>
</Link>
</div>
<div className={`playlist-desc ${viewLayout}`}>
{playlist.playlist_type != 'custom' && (
<Link to={Routes.Channel(playlist.playlist_channel_id)}>
<h3>{playlist.playlist_channel}</h3>
</Link>
)}
<Link to={Routes.Playlist(playlist.playlist_id)}>
<h2>{playlist.playlist_name}</h2>
</Link>
<p>Last refreshed: {formatDate(playlist.playlist_last_refresh)}</p>
{playlist.playlist_type != 'custom' && (
<>
{playlist.playlist_subscribed && (
<Button
label="Unsubscribe"
className="unsubscribe"
type="button"
title={`Unsubscribe from ${playlist.playlist_name}`}
onClick={async () => {
await updatePlaylistSubscription(playlist.playlist_id, false);
setRefresh(true);
}}
/>
)}
{!playlist.playlist_subscribed && (
<Button
label="Subscribe"
type="button"
title={`Subscribe to ${playlist.playlist_name}`}
onClick={async () => {
await updatePlaylistSubscription(playlist.playlist_id, true);
setRefresh(true);
}}
/>
)}
</>
)}
</div>
</div>
);
})}
</>
);
};
export default PlaylistList;

View File

@@ -0,0 +1,17 @@
import { useEffect } from 'react';
import { useLocation, useSearchParams } from 'react-router-dom';
const ScrollToTopOnNavigate = () => {
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const page = searchParams.get('page');
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname, page]);
return null;
};
export default ScrollToTopOnNavigate;

View File

@@ -0,0 +1,116 @@
const SearchExampleQueries = () => {
return (
<div id="multi-search-results-placeholder">
<div>
<h2>Example queries</h2>
<ul>
<li>
<span className="value">music video</span> basic search
</li>
<li>
<span>video: active:</span>
<span className="value">no</span> all videos deleted from YouTube
</li>
<li>
<span>video:</span>
<span className="value">learn javascript</span>
<span> channel:</span>
<span className="value">corey schafer</span>
<span> active:</span>
<span className="value">yes</span>
</li>
<li>
<span>channel:</span>
<span className="value">linux</span>
<span> subscribed:</span>
<span className="value">yes</span>
</li>
<li>
<span>playlist:</span>
<span className="value">backend engineering</span>
<span> active:</span>
<span className="value">yes</span>
<span> subscribed:</span>
<span className="value">yes</span>
</li>
</ul>
</div>
<div>
<h2>Keywords cheatsheet</h2>
<p>
For detailed usage check{' '}
<a href="https://docs.tubearchivist.com/search/" target="_blank">
wiki
</a>
.
</p>
<div>
<ul>
<li>
<span>simple:</span> (implied) search in video titles, channel names and playlist
titles
</li>
<li>
<span>video:</span> search in video titles, tags and category field
<ul>
<li>
<span>channel:</span> channel name
</li>
<li>
<span>active:</span>
<span className="value">yes/no</span> whether the video is still active on
YouTube
</li>
</ul>
</li>
<li>
<span>channel:</span> search in channel name and channel description
<ul>
<li>
<span>subscribed:</span>
<span className="value">yes/no</span> whether you are subscribed to the channel
</li>
<li>
<span>active:</span>
<span className="value">yes/no</span> whether the video is still active on
YouTube
</li>
</ul>
</li>
<li>
<span>playlist:</span> search in channel name and channel description
<ul>
<li>
<span>subscribed:</span>
<span className="value">yes/no</span> whether you are subscribed to the channel
</li>
<li>
<span>active:</span>
<span className="value">yes/no</span> whether the video is still active on
YouTube
</li>
</ul>
</li>
<li>
<span>full:</span> search in video subtitles
<ul>
<li>
<span>lang:</span> subtitles language (use two-letter ISO country code, same as
the one from settings page)
</li>
<li>
<span>source:</span>
<span className="value">auto/user</span> <i>auto</i> to search though
auto-generated subtitles only, or <i>user</i> to search through user-uploaded
subtitles only
</li>
</ul>
</li>
</ul>
</div>
</div>
</div>
);
};
export default SearchExampleQueries;

View File

@@ -0,0 +1,36 @@
import { Link, useOutletContext } from 'react-router-dom';
import Routes from '../configuration/routes/RouteList';
import { OutletContextType } from '../pages/Base';
const SettingsNavigation = () => {
const { isAdmin } = useOutletContext() as OutletContextType;
return (
<>
<div className="info-box-item child-page-nav">
<Link to={Routes.SettingsDashboard}>
<h3>Dashboard</h3>
</Link>
<Link to={Routes.SettingsUser}>
<h3>User</h3>
</Link>
{isAdmin && (
<>
<Link to={Routes.SettingsApplication}>
<h3>Application</h3>
</Link>
<Link to={Routes.SettingsScheduling}>
<h3>Scheduling</h3>
</Link>
<Link to={Routes.SettingsActions}>
<h3>Actions</h3>
</Link>
</>
)}
</div>
</>
);
};
export default SettingsNavigation;

View File

@@ -0,0 +1,26 @@
type StatsInfoBoxItemType = {
title: string;
card: Record<string, string | number | undefined>;
};
const StatsInfoBoxItem = ({ title, card }: StatsInfoBoxItemType) => {
return (
<div className="info-box-item">
<h3>{title}</h3>
<table className="agg-channel-table">
<tbody>
{Object.entries(card).map(([key, value]) => {
return (
<tr key={key}>
<td className="agg-channel-name">{key}: </td>
<td className="agg-channel-right-align">{value}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
};
export default StatsInfoBoxItem;

View File

@@ -0,0 +1,92 @@
import { Link, useSearchParams } from 'react-router-dom';
import Routes from '../configuration/routes/RouteList';
import iconPlay from '/img/icon-play.svg';
import Linkify from './Linkify';
import getApiUrl from '../configuration/getApiUrl';
type SubtitleListType = {
subtitle_index: number;
subtitle_line: string;
subtitle_start: string;
subtitle_fragment_id: string;
subtitle_end: string;
youtube_id: string;
title: string;
subtitle_channel: string;
subtitle_channel_id: string;
subtitle_last_refresh: number;
subtitle_lang: string;
subtitle_source: string;
vid_thumb_url: string;
_index: string;
_score: number;
};
type SubtitleListProps = {
subtitleList: SubtitleListType[] | undefined;
};
const stripNanoSecs = (time: string) => {
return time.split('.').shift();
};
const SubtitleList = ({ subtitleList }: SubtitleListProps) => {
const [, setSearchParams] = useSearchParams();
if (!subtitleList || subtitleList.length === 0) {
return <p>No fulltext results found.</p>;
}
return (
<>
{subtitleList.map(subtitle => {
return (
<div className="video-item list">
<a
onClick={() => {
setSearchParams({
videoId: subtitle.youtube_id,
t: stripNanoSecs(subtitle.subtitle_start) || '00:00:00',
});
}}
>
<div className="video-thumb-wrap list">
<div className="video-thumb">
<img src={`${getApiUrl()}${subtitle.vid_thumb_url}`} alt="video-thumb" />
</div>
<div className="video-play">
<img src={iconPlay} alt="play-icon" />
</div>
</div>
</a>
<div className="video-desc list">
<div>
<Link to={Routes.Channel(subtitle.subtitle_channel_id)}>
<h3>{subtitle.subtitle_channel}</h3>
</Link>
<Link
className="video-more"
to={Routes.VideoAtTimestamp(
subtitle.youtube_id,
stripNanoSecs(subtitle.subtitle_start) || '00:00:00',
)}
>
<h2>{subtitle.title}</h2>
</Link>
</div>
<p>
{stripNanoSecs(subtitle.subtitle_start)} - {stripNanoSecs(subtitle.subtitle_end)}
</p>
<p>
<Linkify ignoreLineBreak>{subtitle.subtitle_line}</Linkify>
</p>
<span className="settings-current">Score: {subtitle._score}</span>
</div>
</div>
);
})}
</>
);
};
export default SubtitleList;

View File

@@ -0,0 +1,41 @@
import { VideoType, ViewLayoutType } from '../pages/Home';
import VideoListItem from './VideoListItem';
type VideoListProps = {
videoList: VideoType[] | undefined;
viewLayout: ViewLayoutType;
playlistId?: string;
showReorderButton?: boolean;
refreshVideoList: (refresh: boolean) => void;
};
const VideoList = ({
videoList,
viewLayout,
playlistId,
showReorderButton = false,
refreshVideoList,
}: VideoListProps) => {
if (!videoList || videoList.length === 0) {
return <p>No videos found.</p>;
}
return (
<>
{videoList.map(video => {
return (
<VideoListItem
key={video.youtube_id}
video={video}
viewLayout={viewLayout}
playlistId={playlistId}
showReorderButton={showReorderButton}
refreshVideoList={refreshVideoList}
/>
);
})}
</>
);
};
export default VideoList;

View File

@@ -0,0 +1,126 @@
import { Link, useSearchParams } from 'react-router-dom';
import Routes from '../configuration/routes/RouteList';
import { VideoType, ViewLayoutType } from '../pages/Home';
import iconPlay from '/img/icon-play.svg';
import iconDotMenu from '/img/icon-dot-menu.svg';
import defaultVideoThumb from '/img/default-video-thumb.jpg';
import updateWatchedState from '../api/actions/updateWatchedState';
import formatDate from '../functions/formatDates';
import WatchedCheckBox from './WatchedCheckBox';
import MoveVideoMenu from './MoveVideoMenu';
import { useState } from 'react';
import getApiUrl from '../configuration/getApiUrl';
type VideoListItemProps = {
video: VideoType;
viewLayout: ViewLayoutType;
playlistId?: string;
showReorderButton?: boolean;
refreshVideoList: (refresh: boolean) => void;
};
const VideoListItem = ({
video,
viewLayout,
playlistId,
showReorderButton = false,
refreshVideoList,
}: VideoListItemProps) => {
const [, setSearchParams] = useSearchParams();
const [showReorderMenu, setShowReorderMenu] = useState(false);
if (!video) {
return <p>No video found.</p>;
}
return (
<div className={`video-item ${viewLayout}`}>
<a
onClick={() => {
setSearchParams({ videoId: video.youtube_id });
}}
>
<div className={`video-thumb-wrap ${viewLayout}`}>
<div className="video-thumb">
<picture>
<img src={`${getApiUrl()}${video.vid_thumb_url}`} alt="video-thumb" />
<source srcSet={defaultVideoThumb} />
</picture>
{video.player.progress && (
<div
className="video-progress-bar"
id={`progress-${video.youtube_id}`}
style={{
width: `${video.player.progress}%`,
}}
></div>
)}
{!video.player.progress && (
<div
className="video-progress-bar"
id={`progress-${video.youtube_id}`}
style={{ width: '0%' }}
></div>
)}
</div>
<div className="video-play">
<img src={iconPlay} alt="play-icon" />
</div>
</div>
</a>
<div className={`video-desc ${viewLayout}`}>
<div className="video-desc-player" id={`video-info-${video.youtube_id}`}>
<WatchedCheckBox
watched={video.player.watched}
onClick={async status => {
await updateWatchedState({
id: video.youtube_id,
is_watched: status,
});
refreshVideoList(true);
}}
/>
<span>
{formatDate(video.published)} | {video.player.duration_str}
</span>
</div>
<div className="video-desc-details">
<div>
<Link to={Routes.Channel(video.channel.channel_id)}>
<h3>{video.channel.channel_name}</h3>
</Link>
<Link className="video-more" to={Routes.Video(video.youtube_id)}>
<h2>{video.title}</h2>
</Link>
</div>
{showReorderButton && !showReorderMenu && (
<img
src={iconDotMenu}
alt="dot-menu-icon"
className="dot-button"
title="More actions"
onClick={() => {
setShowReorderMenu(true);
}}
/>
)}
</div>
{showReorderButton && showReorderMenu && (
<MoveVideoMenu
playlistId={playlistId}
videoId={video.youtube_id}
setCloseMenu={status => setShowReorderMenu(!status)}
setRefresh={refreshVideoList}
/>
)}
</div>
</div>
);
};
export default VideoListItem;

View File

@@ -0,0 +1,254 @@
import updateVideoProgressById from '../api/actions/updateVideoProgressById';
import updateWatchedState from '../api/actions/updateWatchedState';
import { SponsorBlockSegmentType, SponsorBlockType, VideoResponseType } from '../pages/Video';
import watchedThreshold from '../functions/watchedThreshold';
import Notifications from './Notifications';
import { Dispatch, SetStateAction, SyntheticEvent, useState } from 'react';
import formatTime from '../functions/formatTime';
import { useSearchParams } from 'react-router-dom';
import getApiUrl from '../configuration/getApiUrl';
type VideoTag = SyntheticEvent<HTMLVideoElement, Event>;
export type SkippedSegmentType = {
from: number;
to: number;
};
export type SponsorSegmentsSkippedType = Record<string, SkippedSegmentType>;
type Subtitle = {
name: string;
source: string;
lang: string;
media_url: string;
};
type SubtitlesProp = {
subtitles: Subtitle[];
};
const Subtitles = ({ subtitles }: SubtitlesProp) => {
return subtitles.map((subtitle: Subtitle) => {
let label = subtitle.name;
if (subtitle.source === 'auto') {
label += ' - auto';
}
return (
<track
key={subtitle.name}
label={label}
kind="subtitles"
srcLang={subtitle.lang}
src={`${getApiUrl()}${subtitle.media_url}`}
/>
);
});
};
const handleTimeUpdate =
(
youtubeId: string,
duration: number,
watched: boolean,
sponsorBlock?: SponsorBlockType,
setSponsorSegmentSkipped?: Dispatch<SetStateAction<SponsorSegmentsSkippedType>>,
) =>
async (videoTag: VideoTag) => {
const currentTime = Number(videoTag.currentTarget.currentTime);
if (sponsorBlock && sponsorBlock.segments) {
sponsorBlock.segments.forEach((segment: SponsorBlockSegmentType) => {
const [from, to] = segment.segment;
if (currentTime >= from && currentTime <= from + 0.3) {
videoTag.currentTarget.currentTime = to;
setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
return { ...segments, [segment.UUID]: { from, to } };
});
}
if (currentTime > to + 10) {
setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
return { ...segments, [segment.UUID]: { from: 0, to: 0 } };
});
}
});
}
if (currentTime < 10) return;
if (Number((currentTime % 10).toFixed(1)) <= 0.2) {
// Check progress every 10 seconds or else progress is checked a few times a second
await updateVideoProgressById({
youtubeId,
currentProgress: currentTime,
});
if (!watched) {
// Check if video is already marked as watched
if (watchedThreshold(currentTime, duration)) {
await updateWatchedState({
id: youtubeId,
is_watched: true,
});
}
}
}
};
const handleVideoEnd =
(
youtubeId: string,
watched: boolean,
setSponsorSegmentSkipped?: Dispatch<SetStateAction<SponsorSegmentsSkippedType>>,
) =>
async () => {
if (!watched) {
// Check if video is already marked as watched
await updateWatchedState({ id: youtubeId, is_watched: true });
}
setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
const keys = Object.keys(segments);
keys.forEach(uuid => {
segments[uuid] = { from: 0, to: 0 };
});
return segments;
});
};
export type VideoProgressType = {
youtube_id: string;
user_id: number;
position: number;
};
type VideoPlayerProps = {
video: VideoResponseType;
videoProgress?: VideoProgressType;
sponsorBlock?: SponsorBlockType;
embed?: boolean;
};
const VideoPlayer = ({ video, videoProgress, sponsorBlock, embed }: VideoPlayerProps) => {
const [searchParams] = useSearchParams();
const searchParamVideoProgress = searchParams.get('t');
const [skippedSegments, setSkippedSegments] = useState<SponsorSegmentsSkippedType>({});
const videoId = video.data.youtube_id;
const videoUrl = video.data.media_url;
const videoThumbUrl = video.data.vid_thumb_url;
const watched = video.data.player.watched;
const duration = video.data.player.duration;
const videoSubtitles = video.data.subtitles;
let videoSrcProgress = Number(videoProgress?.position) > 0 ? Number(videoProgress?.position) : '';
if (searchParamVideoProgress !== null) {
videoSrcProgress = searchParamVideoProgress;
}
const autoplay = false;
return (
<>
<div id="player" className={embed ? '' : 'player-wrapper'}>
<div className={embed ? '' : 'video-main'}>
<video
poster={`${getApiUrl()}${videoThumbUrl}`}
onVolumeChange={(videoTag: VideoTag) => {
localStorage.setItem('playerVolume', videoTag.currentTarget.volume.toString());
}}
onLoadStart={(videoTag: VideoTag) => {
videoTag.currentTarget.volume = Number(localStorage.getItem('playerVolume')) ?? 1;
}}
onTimeUpdate={handleTimeUpdate(
videoId,
duration,
watched,
sponsorBlock,
setSkippedSegments,
)}
onPause={async (videoTag: VideoTag) => {
const currentTime = Number(videoTag.currentTarget.currentTime);
if (currentTime < 10) return;
await updateVideoProgressById({
youtubeId: videoId,
currentProgress: currentTime,
});
}}
onEnded={handleVideoEnd(videoId, watched)}
autoPlay={autoplay}
controls
width="100%"
playsInline
id="video-item"
>
<source
src={`${getApiUrl()}${videoUrl}#t=${videoSrcProgress}`}
type="video/mp4"
id="video-source"
/>
{videoSubtitles && <Subtitles subtitles={videoSubtitles} />}
</video>
</div>
</div>
<Notifications pageName="all" />
<div className="sponsorblock" id="sponsorblock">
{sponsorBlock?.is_enabled && (
<>
{sponsorBlock.segments.length == 0 && (
<h4>
This video doesn't have any sponsor segments added. To add a segment go to{' '}
<u>
<a href={`https://www.youtube.com/watch?v=${videoId}`}>this video on YouTube</a>
</u>{' '}
and add a segment using the{' '}
<u>
<a href="https://sponsor.ajay.app/">SponsorBlock</a>
</u>{' '}
extension.
</h4>
)}
{sponsorBlock.has_unlocked && (
<h4>
This video has unlocked sponsor segments. Go to{' '}
<u>
<a href={`https://www.youtube.com/watch?v=${videoId}`}>this video on YouTube</a>
</u>{' '}
and vote on the segments using the{' '}
<u>
<a href="https://sponsor.ajay.app/">SponsorBlock</a>
</u>{' '}
extension.
</h4>
)}
{Object.values(skippedSegments).map(({ from, to }) => {
return (
<>
{from !== 0 && to !== 0 && (
<h3>
Skipped sponsor segment from {formatTime(from)} to {formatTime(to)}.
</h3>
)}
</>
);
})}
</>
)}
</div>
</>
);
};
export default VideoPlayer;

View File

@@ -0,0 +1,53 @@
import { Fragment } from 'react';
import humanFileSize from '../functions/humanFileSize';
import StatsInfoBoxItem from './StatsInfoBoxItem';
import formatNumbers from '../functions/formatNumbers';
import { VideoStatsType } from '../pages/SettingsDashboard';
type VideoTypeStatsProps = {
videoStats?: VideoStatsType;
useSI: boolean;
};
const VideoTypeStats = ({ videoStats, useSI }: VideoTypeStatsProps) => {
if (!videoStats) {
return <p id="loading">Loading...</p>;
}
const cards = [
{
title: 'Regular Videos: ',
data: {
Videos: formatNumbers(videoStats?.type_videos?.doc_count || 0),
['Media Size']: humanFileSize(videoStats?.type_videos?.media_size || 0, useSI),
Duration: videoStats?.type_videos?.duration_str || 'NA',
},
},
{
title: 'Shorts: ',
data: {
Videos: formatNumbers(videoStats?.type_shorts?.doc_count || 0),
['Media Size']: humanFileSize(videoStats?.type_shorts?.media_size || 0, useSI),
Duration: videoStats?.type_shorts?.duration_str || 'NA',
},
},
{
title: 'Streams: ',
data: {
Videos: formatNumbers(videoStats?.type_streams?.doc_count || 0),
['Media Size']: humanFileSize(videoStats?.type_streams?.media_size || 0, useSI),
Duration: videoStats?.type_streams?.duration_str || 'NA',
},
},
];
return cards.map(card => {
return (
<Fragment key={card.title}>
<StatsInfoBoxItem title={card.title} card={card.data} />
</Fragment>
);
});
};
export default VideoTypeStats;

View File

@@ -0,0 +1,65 @@
import { Fragment } from 'react';
import StatsInfoBoxItem from './StatsInfoBoxItem';
import formatNumbers from '../functions/formatNumbers';
import { WatchProgressStatsType } from '../pages/SettingsDashboard';
const formatProgress = (progress: number) => {
return (Number(progress) * 100).toFixed(2) ?? '0';
};
const formatTitle = (title: string, progress: number, progressFormatted: string) => {
const hasProgess = !!progress;
return hasProgess ? `${progressFormatted}% ${title}` : title;
};
type WatchProgressStatsProps = {
watchProgressStats?: WatchProgressStatsType;
};
const WatchProgressStats = ({ watchProgressStats }: WatchProgressStatsProps) => {
if (!watchProgressStats) {
return <p id="loading">Loading...</p>;
}
const titleWatched = formatTitle(
'Watched',
watchProgressStats?.watched?.progress,
formatProgress(watchProgressStats?.watched?.progress),
);
const titleUnwatched = formatTitle(
'Unwatched',
watchProgressStats?.unwatched?.progress,
formatProgress(watchProgressStats?.unwatched?.progress),
);
const cards = [
{
title: titleWatched,
data: {
Videos: formatNumbers(watchProgressStats?.watched?.items ?? 0),
Seconds: formatNumbers(watchProgressStats?.watched?.duration ?? 0),
Duration: watchProgressStats?.watched?.duration_str ?? '0s',
},
},
{
title: titleUnwatched,
data: {
Videos: formatNumbers(watchProgressStats?.unwatched?.items ?? 0),
Seconds: formatNumbers(watchProgressStats?.unwatched?.duration ?? 0),
Duration: watchProgressStats?.unwatched?.duration_str ?? '0s',
},
},
];
return cards.map(card => {
return (
<Fragment key={card.title}>
<StatsInfoBoxItem title={card.title} card={card.data} />
</Fragment>
);
});
};
export default WatchProgressStats;

View File

@@ -0,0 +1,38 @@
import iconUnseen from '/img/icon-unseen.svg';
import iconSeen from '/img/icon-seen.svg';
type WatchedCheckBoxProps = {
watched: boolean;
onClick?: (status: boolean) => void;
};
const WatchedCheckBox = ({ watched, onClick }: WatchedCheckBoxProps) => {
return (
<>
{watched && (
<img
src={iconSeen}
alt="seen-icon"
className="watch-button"
title="Mark as unwatched"
onClick={async () => {
onClick?.(false);
}}
/>
)}
{!watched && (
<img
src={iconUnseen}
alt="unseen-icon"
className="watch-button"
title="Mark as watched"
onClick={async () => {
onClick?.(true);
}}
/>
)}
</>
);
};
export default WatchedCheckBox;

View File

@@ -0,0 +1,7 @@
import './css/dark.css';
const DarkStylesheet = () => {
return <></>;
};
export default DarkStylesheet;

View File

@@ -0,0 +1,7 @@
import './css/light.css';
const LightStylesheet = () => {
return <></>;
};
export default LightStylesheet;

View File

@@ -0,0 +1,7 @@
import './css/matrix.css';
const MatrixStylesheet = () => {
return <></>;
};
export default MatrixStylesheet;

View File

@@ -0,0 +1,7 @@
import './css/midnight.css';
const MidnightStylesheet = () => {
return <></>;
};
export default MidnightStylesheet;

View File

@@ -0,0 +1,16 @@
:root {
--main-bg: #00202f;
--highlight-bg: #00293b;
--highlight-error: #990202;
--highlight-error-light: #c44343;
--highlight-bg-transparent: #00293baf;
--main-font: #eeeeee;
--accent-font-dark: #259485;
--accent-font-light: #97d4c8;
--img-filter: invert(50%) sepia(9%) saturate(2940%) hue-rotate(122deg) brightness(94%)
contrast(90%);
--img-filter-error: invert(16%) sepia(60%) saturate(3717%) hue-rotate(349deg) brightness(86%)
contrast(120%);
--banner: url('/img/banner-tube-archivist-dark.png');
--logo: url('/img/logo-tube-archivist-dark.png');
}

View File

@@ -0,0 +1,16 @@
:root {
--main-bg: #eeeeee;
--highlight-bg: #d9e0d9;
--highlight-error: #990202;
--highlight-error-light: #c44343;
--highlight-bg-transparent: #00293baf;
--main-font: #00202f;
--accent-font-dark: #259485;
--accent-font-light: #35b399;
--img-filter: invert(50%) sepia(9%) saturate(2940%) hue-rotate(122deg) brightness(94%)
contrast(90%);
--img-filter-error: invert(16%) sepia(60%) saturate(3717%) hue-rotate(349deg) brightness(86%)
contrast(120%);
--banner: url('/img/banner-tube-archivist-light.png');
--logo: url('/img/logo-tube-archivist-light.png');
}

View File

@@ -0,0 +1,69 @@
:root {
--main-bg: #000000;
--highlight-bg: #080808;
--highlight-error: #880000;
--highlight-error-light: #aa0000;
--highlight-bg-transparent: #0c0c0caf;
--main-font: #00aa00;
--accent-font-dark: #007700;
--accent-font-light: #00aa00;
--img-filter: brightness(0) saturate(100%) invert(45%) sepia(100%) saturate(3710%)
hue-rotate(96deg) brightness(100%) contrast(102%);
--img-filter-error: invert(16%) sepia(60%) saturate(3717%) hue-rotate(349deg) brightness(86%)
contrast(120%);
--banner: url('/img/banner-tube-archivist-dark.png');
--logo: url('/img/logo-tube-archivist-dark.png');
--outline: 1px solid green;
--filter: hue-rotate(310deg);
}
.settings-group {
outline: var(--outline);
}
.info-box-item {
outline: var(--outline);
}
.footer {
outline: var(--outline);
}
.top-banner img {
filter: var(--filter);
}
.icon-text {
outline: var(--outline);
}
.video-item {
outline: var(--outline);
}
.channel-banner {
outline: var(--outline);
}
.description-box {
outline: var(--outline);
}
.video-player {
outline: var(--outline);
}
#notification {
outline: var(--outline);
}
textarea {
background-color: var(--highlight-bg);
outline: var(--outline);
color: var(--main-font);
}
input {
background-color: var(--highlight-bg);
color: var(--main-font);
}

View File

@@ -0,0 +1,16 @@
:root {
--main-bg: #000000;
--highlight-bg: #0c0c0c;
--highlight-error: #220000;
--highlight-error-light: #330000;
--highlight-bg-transparent: #0c0c0caf;
--main-font: #888888;
--accent-font-dark: #555555;
--accent-font-light: #999999;
--img-filter: invert(50%) sepia(9%) saturate(2940%) hue-rotate(122deg) brightness(94%)
contrast(90%);
--img-filter-error: invert(16%) sepia(60%) saturate(3717%) hue-rotate(349deg) brightness(86%)
contrast(120%);
--banner: url('/img/banner-tube-archivist-dark.png');
--logo: url('/img/logo-tube-archivist-dark.png');
}

View File

@@ -0,0 +1,29 @@
export const ColourConstant = {
Dark: 'dark.css',
Light: 'light.css',
Matrix: 'matrix.css',
Midnight: 'midnight.css',
};
export type ColourVariants = 'dark.css' | 'light.css' | 'matrix.css' | 'midnight.css';
const importColours = (stylesheet: ColourVariants | undefined) => {
switch (stylesheet) {
case ColourConstant.Dark:
return import('./components/Dark');
case ColourConstant.Matrix:
return import('./components/Matrix');
case ColourConstant.Midnight:
return import('./components/Midnight');
case ColourConstant.Light:
return import('./components/Light');
default:
return import('./components/Dark');
}
};
export default importColours;

View File

@@ -0,0 +1,11 @@
export const ViewStyleNames = {
home: 'view_style_home',
channel: 'view_style_channel',
downloads: 'view_style_downloads',
playlist: 'view_style_playlist',
};
export const ViewStyles = {
grid: 'grid',
list: 'list',
};

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