mirror of
https://git.vectorsigma.ru/public/tubearchivist.git
synced 2026-08-08 03:49:27 +00:00
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:
30
frontend/src/api/actions/createCustomPlaylist.ts
Normal file
30
frontend/src/api/actions/createCustomPlaylist.ts
Normal 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;
|
||||
28
frontend/src/api/actions/deleteApiToken.ts
Normal file
28
frontend/src/api/actions/deleteApiToken.ts
Normal 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;
|
||||
29
frontend/src/api/actions/deleteChannel.ts
Normal file
29
frontend/src/api/actions/deleteChannel.ts
Normal 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;
|
||||
29
frontend/src/api/actions/deleteDownloadById.ts
Normal file
29
frontend/src/api/actions/deleteDownloadById.ts
Normal 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;
|
||||
37
frontend/src/api/actions/deleteDownloadQueueByFilter.ts
Normal file
37
frontend/src/api/actions/deleteDownloadQueueByFilter.ts
Normal 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;
|
||||
34
frontend/src/api/actions/deletePlaylist.ts
Normal file
34
frontend/src/api/actions/deletePlaylist.ts
Normal 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;
|
||||
29
frontend/src/api/actions/deleteVideo.ts
Normal file
29
frontend/src/api/actions/deleteVideo.ts
Normal 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;
|
||||
29
frontend/src/api/actions/deleteVideoProgressById.ts
Normal file
29
frontend/src/api/actions/deleteVideoProgressById.ts
Normal 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;
|
||||
29
frontend/src/api/actions/queueBackup.ts
Normal file
29
frontend/src/api/actions/queueBackup.ts
Normal 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;
|
||||
48
frontend/src/api/actions/queueReindex.ts
Normal file
48
frontend/src/api/actions/queueReindex.ts
Normal 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;
|
||||
29
frontend/src/api/actions/queueSnapshot.ts
Normal file
29
frontend/src/api/actions/queueSnapshot.ts
Normal 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;
|
||||
29
frontend/src/api/actions/restoreBackup.ts
Normal file
29
frontend/src/api/actions/restoreBackup.ts
Normal 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;
|
||||
29
frontend/src/api/actions/restoreSnapshot.ts
Normal file
29
frontend/src/api/actions/restoreSnapshot.ts
Normal 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;
|
||||
39
frontend/src/api/actions/signIn.ts
Normal file
39
frontend/src/api/actions/signIn.ts
Normal 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;
|
||||
27
frontend/src/api/actions/stopTaskByName.ts
Normal file
27
frontend/src/api/actions/stopTaskByName.ts
Normal 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;
|
||||
27
frontend/src/api/actions/updateAppsettingsConfig.ts
Normal file
27
frontend/src/api/actions/updateAppsettingsConfig.ts
Normal 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;
|
||||
29
frontend/src/api/actions/updateChannelSubscription.ts
Normal file
29
frontend/src/api/actions/updateChannelSubscription.ts
Normal 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;
|
||||
32
frontend/src/api/actions/updateCookie.ts
Normal file
32
frontend/src/api/actions/updateCookie.ts
Normal 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;
|
||||
33
frontend/src/api/actions/updateCustomPlaylist.ts
Normal file
33
frontend/src/api/actions/updateCustomPlaylist.ts
Normal 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;
|
||||
34
frontend/src/api/actions/updateDownloadQueue.ts
Normal file
34
frontend/src/api/actions/updateDownloadQueue.ts
Normal 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;
|
||||
31
frontend/src/api/actions/updateDownloadQueueStatusById.ts
Normal file
31
frontend/src/api/actions/updateDownloadQueueStatusById.ts
Normal 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;
|
||||
29
frontend/src/api/actions/updatePlaylistSubscription.ts
Normal file
29
frontend/src/api/actions/updatePlaylistSubscription.ts
Normal 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;
|
||||
32
frontend/src/api/actions/updateTaskByName.ts
Normal file
32
frontend/src/api/actions/updateTaskByName.ts
Normal 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;
|
||||
56
frontend/src/api/actions/updateUserConfig.ts
Normal file
56
frontend/src/api/actions/updateUserConfig.ts
Normal 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;
|
||||
34
frontend/src/api/actions/updateVideoProgressById.ts
Normal file
34
frontend/src/api/actions/updateVideoProgressById.ts
Normal 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;
|
||||
37
frontend/src/api/actions/updateWatchedState.ts
Normal file
37
frontend/src/api/actions/updateWatchedState.ts
Normal 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;
|
||||
32
frontend/src/api/loader/loadApiToken.ts
Normal file
32
frontend/src/api/loader/loadApiToken.ts
Normal 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;
|
||||
54
frontend/src/api/loader/loadAppsettingsConfig.ts
Normal file
54
frontend/src/api/loader/loadAppsettingsConfig.ts
Normal 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;
|
||||
21
frontend/src/api/loader/loadAuth.ts
Normal file
21
frontend/src/api/loader/loadAuth.ts
Normal 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;
|
||||
23
frontend/src/api/loader/loadBackupList.ts
Normal file
23
frontend/src/api/loader/loadBackupList.ts
Normal 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;
|
||||
23
frontend/src/api/loader/loadChannelById.ts
Normal file
23
frontend/src/api/loader/loadChannelById.ts
Normal 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;
|
||||
33
frontend/src/api/loader/loadChannelList.ts
Normal file
33
frontend/src/api/loader/loadChannelList.ts
Normal 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;
|
||||
30
frontend/src/api/loader/loadChannelNav.ts
Normal file
30
frontend/src/api/loader/loadChannelNav.ts
Normal 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;
|
||||
23
frontend/src/api/loader/loadCommentsbyVideoId.ts
Normal file
23
frontend/src/api/loader/loadCommentsbyVideoId.ts
Normal 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;
|
||||
37
frontend/src/api/loader/loadDownloadAggs.ts
Normal file
37
frontend/src/api/loader/loadDownloadAggs.ts
Normal 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;
|
||||
35
frontend/src/api/loader/loadDownloadQueue.ts
Normal file
35
frontend/src/api/loader/loadDownloadQueue.ts
Normal 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;
|
||||
30
frontend/src/api/loader/loadNotifications.ts
Normal file
30
frontend/src/api/loader/loadNotifications.ts
Normal 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;
|
||||
23
frontend/src/api/loader/loadPlaylistById.ts
Normal file
23
frontend/src/api/loader/loadPlaylistById.ts
Normal 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;
|
||||
50
frontend/src/api/loader/loadPlaylistList.ts
Normal file
50
frontend/src/api/loader/loadPlaylistList.ts
Normal 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;
|
||||
23
frontend/src/api/loader/loadSearch.ts
Normal file
23
frontend/src/api/loader/loadSearch.ts
Normal 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;
|
||||
23
frontend/src/api/loader/loadSimmilarVideosById.ts
Normal file
23
frontend/src/api/loader/loadSimmilarVideosById.ts
Normal 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;
|
||||
23
frontend/src/api/loader/loadSnapshots.ts
Normal file
23
frontend/src/api/loader/loadSnapshots.ts
Normal 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;
|
||||
23
frontend/src/api/loader/loadSponsorblockByVideoId.ts
Normal file
23
frontend/src/api/loader/loadSponsorblockByVideoId.ts
Normal 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;
|
||||
28
frontend/src/api/loader/loadStatsBiggestChannels.ts
Normal file
28
frontend/src/api/loader/loadStatsBiggestChannels.ts
Normal 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;
|
||||
23
frontend/src/api/loader/loadStatsChannel.ts
Normal file
23
frontend/src/api/loader/loadStatsChannel.ts
Normal 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;
|
||||
23
frontend/src/api/loader/loadStatsDownload.ts
Normal file
23
frontend/src/api/loader/loadStatsDownload.ts
Normal 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;
|
||||
23
frontend/src/api/loader/loadStatsDownloadHistory.ts
Normal file
23
frontend/src/api/loader/loadStatsDownloadHistory.ts
Normal 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;
|
||||
23
frontend/src/api/loader/loadStatsPlaylist.ts
Normal file
23
frontend/src/api/loader/loadStatsPlaylist.ts
Normal 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;
|
||||
23
frontend/src/api/loader/loadStatsVideo.ts
Normal file
23
frontend/src/api/loader/loadStatsVideo.ts
Normal 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;
|
||||
23
frontend/src/api/loader/loadStatsWatchProgress.ts
Normal file
23
frontend/src/api/loader/loadStatsWatchProgress.ts
Normal 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;
|
||||
24
frontend/src/api/loader/loadUserConfig.ts
Normal file
24
frontend/src/api/loader/loadUserConfig.ts
Normal 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;
|
||||
23
frontend/src/api/loader/loadVideoById.ts
Normal file
23
frontend/src/api/loader/loadVideoById.ts
Normal 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;
|
||||
65
frontend/src/api/loader/loadVideoListByPage.ts
Normal file
65
frontend/src/api/loader/loadVideoListByPage.ts
Normal 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;
|
||||
48
frontend/src/api/loader/loadVideoNav.ts
Normal file
48
frontend/src/api/loader/loadVideoNav.ts
Normal 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;
|
||||
23
frontend/src/api/loader/loadVideoProgressById.ts
Normal file
23
frontend/src/api/loader/loadVideoProgressById.ts
Normal 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;
|
||||
57
frontend/src/components/ApplicationStats.tsx
Normal file
57
frontend/src/components/ApplicationStats.tsx
Normal 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;
|
||||
108
frontend/src/components/BiggestChannelsStats.tsx
Normal file
108
frontend/src/components/BiggestChannelsStats.tsx
Normal 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;
|
||||
40
frontend/src/components/Button.tsx
Normal file
40
frontend/src/components/Button.tsx
Normal 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;
|
||||
21
frontend/src/components/ChannelBanner.tsx
Normal file
21
frontend/src/components/ChannelBanner.tsx
Normal 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;
|
||||
21
frontend/src/components/ChannelIcon.tsx
Normal file
21
frontend/src/components/ChannelIcon.tsx
Normal 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;
|
||||
83
frontend/src/components/ChannelList.tsx
Normal file
83
frontend/src/components/ChannelList.tsx
Normal 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;
|
||||
76
frontend/src/components/ChannelOverview.tsx
Normal file
76
frontend/src/components/ChannelOverview.tsx
Normal 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;
|
||||
106
frontend/src/components/CommentBox.tsx
Normal file
106
frontend/src/components/CommentBox.tsx
Normal 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;
|
||||
41
frontend/src/components/DownloadHistoryStats.tsx
Normal file
41
frontend/src/components/DownloadHistoryStats.tsx
Normal 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;
|
||||
108
frontend/src/components/DownloadListItem.tsx
Normal file
108
frontend/src/components/DownloadListItem.tsx
Normal 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;
|
||||
178
frontend/src/components/EmbeddableVideoPlayer.tsx
Normal file
178
frontend/src/components/EmbeddableVideoPlayer.tsx
Normal 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;
|
||||
186
frontend/src/components/Filterbar.tsx
Normal file
186
frontend/src/components/Filterbar.tsx
Normal 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;
|
||||
60
frontend/src/components/Footer.tsx
Normal file
60
frontend/src/components/Footer.tsx
Normal 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;
|
||||
27
frontend/src/components/FormattedNumber.tsx
Normal file
27
frontend/src/components/FormattedNumber.tsx
Normal 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;
|
||||
230
frontend/src/components/GoogleCast.tsx
Normal file
230
frontend/src/components/GoogleCast.tsx
Normal 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('&', '&'); // 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;
|
||||
34
frontend/src/components/Linkify.tsx
Normal file
34
frontend/src/components/Linkify.tsx
Normal 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;
|
||||
94
frontend/src/components/MoveVideoMenu.tsx
Normal file
94
frontend/src/components/MoveVideoMenu.tsx
Normal 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;
|
||||
42
frontend/src/components/Navigation.tsx
Normal file
42
frontend/src/components/Navigation.tsx
Normal 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;
|
||||
16
frontend/src/components/NavigationItem.tsx
Normal file
16
frontend/src/components/NavigationItem.tsx
Normal 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;
|
||||
101
frontend/src/components/Notifications.tsx
Normal file
101
frontend/src/components/Notifications.tsx
Normal 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;
|
||||
53
frontend/src/components/OverviewStats.tsx
Normal file
53
frontend/src/components/OverviewStats.tsx
Normal 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;
|
||||
213
frontend/src/components/Pagination.tsx
Normal file
213
frontend/src/components/Pagination.tsx
Normal 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;
|
||||
9
frontend/src/components/PaginationDummy.tsx
Normal file
9
frontend/src/components/PaginationDummy.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
const PaginationDummy = () => {
|
||||
return (
|
||||
<div className="boxed-content">
|
||||
<div className="pagination">{/** dummy pagination for padding */}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaginationDummy;
|
||||
85
frontend/src/components/PlaylistList.tsx
Normal file
85
frontend/src/components/PlaylistList.tsx
Normal 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;
|
||||
17
frontend/src/components/ScrollToTop.tsx
Normal file
17
frontend/src/components/ScrollToTop.tsx
Normal 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;
|
||||
116
frontend/src/components/SearchExampleQueries.tsx
Normal file
116
frontend/src/components/SearchExampleQueries.tsx
Normal 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;
|
||||
36
frontend/src/components/SettingsNavigation.tsx
Normal file
36
frontend/src/components/SettingsNavigation.tsx
Normal 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;
|
||||
26
frontend/src/components/StatsInfoBoxItem.tsx
Normal file
26
frontend/src/components/StatsInfoBoxItem.tsx
Normal 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;
|
||||
92
frontend/src/components/SubtitleList.tsx
Normal file
92
frontend/src/components/SubtitleList.tsx
Normal 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;
|
||||
41
frontend/src/components/VideoList.tsx
Normal file
41
frontend/src/components/VideoList.tsx
Normal 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;
|
||||
126
frontend/src/components/VideoListItem.tsx
Normal file
126
frontend/src/components/VideoListItem.tsx
Normal 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;
|
||||
254
frontend/src/components/VideoPlayer.tsx
Normal file
254
frontend/src/components/VideoPlayer.tsx
Normal 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;
|
||||
53
frontend/src/components/VideoTypeStats.tsx
Normal file
53
frontend/src/components/VideoTypeStats.tsx
Normal 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;
|
||||
65
frontend/src/components/WatchProgressStats.tsx
Normal file
65
frontend/src/components/WatchProgressStats.tsx
Normal 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;
|
||||
38
frontend/src/components/WatchedCheckBox.tsx
Normal file
38
frontend/src/components/WatchedCheckBox.tsx
Normal 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;
|
||||
7
frontend/src/configuration/colours/components/Dark.tsx
Normal file
7
frontend/src/configuration/colours/components/Dark.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import './css/dark.css';
|
||||
|
||||
const DarkStylesheet = () => {
|
||||
return <></>;
|
||||
};
|
||||
|
||||
export default DarkStylesheet;
|
||||
7
frontend/src/configuration/colours/components/Light.tsx
Normal file
7
frontend/src/configuration/colours/components/Light.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import './css/light.css';
|
||||
|
||||
const LightStylesheet = () => {
|
||||
return <></>;
|
||||
};
|
||||
|
||||
export default LightStylesheet;
|
||||
7
frontend/src/configuration/colours/components/Matrix.tsx
Normal file
7
frontend/src/configuration/colours/components/Matrix.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import './css/matrix.css';
|
||||
|
||||
const MatrixStylesheet = () => {
|
||||
return <></>;
|
||||
};
|
||||
|
||||
export default MatrixStylesheet;
|
||||
@@ -0,0 +1,7 @@
|
||||
import './css/midnight.css';
|
||||
|
||||
const MidnightStylesheet = () => {
|
||||
return <></>;
|
||||
};
|
||||
|
||||
export default MidnightStylesheet;
|
||||
16
frontend/src/configuration/colours/components/css/dark.css
Normal file
16
frontend/src/configuration/colours/components/css/dark.css
Normal 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');
|
||||
}
|
||||
16
frontend/src/configuration/colours/components/css/light.css
Normal file
16
frontend/src/configuration/colours/components/css/light.css
Normal 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');
|
||||
}
|
||||
69
frontend/src/configuration/colours/components/css/matrix.css
Normal file
69
frontend/src/configuration/colours/components/css/matrix.css
Normal 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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
29
frontend/src/configuration/colours/getColours.ts
Normal file
29
frontend/src/configuration/colours/getColours.ts
Normal 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;
|
||||
11
frontend/src/configuration/constants/ViewStyle.ts
Normal file
11
frontend/src/configuration/constants/ViewStyle.ts
Normal 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
Reference in New Issue
Block a user