@@ -0,0 +1,15 @@ | |||
<!DOCTYPE html> | |||
<html> | |||
<head> | |||
<meta charset="utf-8" name="viewport" content="width=device-width, initial-scale=1"/> | |||
<title>YT Manager Admin</title> | |||
<!-- Compressed CSS --> | |||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/foundation/6.4.3/css/foundation.min.css" /> | |||
</head> | |||
<body> | |||
<h2>Admin Page</h2> | |||
<div id="admin_app"></div> | |||
<script src="./../dist/build.js"></script> | |||
</body> | |||
</html> |
@@ -0,0 +1,20 @@ | |||
<!DOCTYPE html> | |||
<html> | |||
<head> | |||
<meta charset="utf-8" name="viewport" content="width=device-width, initial-scale=1"/> | |||
<title>YT Manager</title> | |||
<!-- Compressed CSS --> | |||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/foundation/6.4.3/css/foundation.min.css" /> | |||
</head> | |||
<body> | |||
<div class="pageheader"> | |||
<h1>Dumb YT Manager</h1> | |||
<p>Youtube banned my account and refuses to say why, taking all my subscribed channels with it. This is a simple scrubscribed channel manager, showing recent releases from each channel and finally proper sub-catagory functionality!</p> | |||
</div> | |||
<div id="app"></div> | |||
<script src="./dist/build.js"></script> | |||
</body> | |||
</html> |
@@ -0,0 +1,31 @@ | |||
{ | |||
"name": "dumbytmanager", | |||
"version": "0.1.0", | |||
"description": "Front end for the DumbYT Manager project", | |||
"main": "index.js", | |||
"repository": "https://gitea.hak8or.com/Almost_There/dumbytmanager", | |||
"author": "hak8or", | |||
"license": "MIT", | |||
"devDependencies": { | |||
"@types/lodash": "^4.14.104", | |||
"axios": "^0.18.0", | |||
"css-loader": "^0.28.10", | |||
"style-loader": "^0.20.2", | |||
"sweetalert2": "^7.13.3", | |||
"ts-loader": "^4.0.0", | |||
"typescript": "^2.7.2", | |||
"uglifyjs-webpack-plugin": "^1.2.2", | |||
"vue": "^2.5.13", | |||
"vue-loader": "^14.1.1", | |||
"vue-template-compiler": "^2.5.13", | |||
"webpack": "^4.0.1", | |||
"webpack-cli": "^2.0.9", | |||
"webpack-dev-server": "^3.1.0" | |||
}, | |||
"scripts": { | |||
"start": "webpack-dev-server --mode development --open", | |||
"dev": "webpack --mode development", | |||
"build": "webpack --mode production", | |||
"test": "echo \"Error: no test specified\" && exit 1" | |||
} | |||
} |
@@ -0,0 +1,10 @@ | |||
# Frontend | |||
## Description | |||
Oh god, front end web development, specifically javascript. | |||
This project makes use of the following: | |||
- Vuejs | |||
Seemingly resonable javascript framework that's well documented and actively developed. | |||
@@ -0,0 +1,126 @@ | |||
<template> | |||
<div> | |||
<div class="grid-x"> | |||
<div class="medium-11 cell"></div> | |||
<div class="medium-1 cell"> | |||
<button type="button" class="button input-group" v-on:click='Expanded = !Expanded'> | |||
<i class="fa fa-plus" aria-hidden="true"></i> Add | |||
</button> | |||
</div> | |||
</div> | |||
<transition name="fade"> | |||
<form v-cloak v-if="Expanded"> | |||
<h2>Add new Channel</h2> | |||
<div class="grid-x"> | |||
<div class="input-group medium-6 cell"> | |||
<span class="input-group-label">Title</span> | |||
<input class="input-group-field" type="text" placeholder="Title" v-model="Title"> | |||
</div> | |||
<div class="input-group medium-1 cell"></div> | |||
<div class="input-group medium-5 cell"> | |||
<span class="input-group-label">Channel</span> | |||
<input class="input-group-field" v-bind:class="{ error: !Valid }" type="text" | |||
placeholder="URL" v-model="Channel_Identification_Box"> | |||
</div> | |||
</div> | |||
<div class="input-group"> | |||
<span class="input-group-label">Description</span> | |||
<input class="input-group-field" type="text" placeholder="Description" v-model="Description"> | |||
</div> | |||
<div class="grid-x"> | |||
<img class="small-4 cell" :src="Thumbnail" /> | |||
<div class="small-7"></div> | |||
<button type="button" style="max-height:50px" class="button input-group small-1 cell" @click="Submit">Submit</button> | |||
</div> | |||
</form> | |||
</transition> | |||
</div> | |||
</template> | |||
<script lang="ts"> | |||
import Vue from "vue"; | |||
import * as Dyt from "../dumbyt"; | |||
import Axios from "axios"; | |||
import SA2 from "sweetalert2"; | |||
// Vue class for keeping state of the videos. | |||
export default Vue.extend({ | |||
data() { | |||
return { | |||
Title: "", | |||
Description: "", | |||
ID: "", | |||
Thumbnail: "", | |||
Valid: false, | |||
Expanded: false, | |||
Channel_Identification_Box : "" | |||
} | |||
}, | |||
watch: { | |||
Channel_Identification_Box(newcontents: string) { | |||
this.GetChannelFromYT(newcontents); | |||
} | |||
}, | |||
methods: { | |||
Submit() : void { | |||
Dyt.channel_modify(this.ID, Dyt.Modification.Add).then((resp) => { | |||
if (resp == null) | |||
SA2( | |||
"Channel Add Success!", | |||
"The channel has been added succesfully, video contents should be updated shortly.", | |||
"success" | |||
); | |||
else | |||
SA2( | |||
"Channel Add Fail!", | |||
"The channel has not been added due to the following: \n" + resp, | |||
"error" | |||
); | |||
}); | |||
// Hide the bars and erase internal state. | |||
this.Expanded = false; | |||
this.Title = ""; | |||
this.Description = ""; | |||
this.ID = ""; | |||
this.Thumbnail = ""; | |||
this.Valid = false; | |||
this.Channel_Identification_Box = ""; | |||
}, | |||
GetChannelFromYT(Channel: string) : void { | |||
// Say it failed first so if we exit early then correctly marked fail. | |||
this.Valid = false; | |||
// Copy over to internal ID box. | |||
this.ID = Channel; | |||
// Remove any potential youtube URL from the field. | |||
const ytchurl = "https://www.youtube.com/channel/"; | |||
if (this.ID.startsWith(ytchurl)) | |||
this.ID = this.ID.replace(ytchurl, ""); | |||
// Check if what remains looks like a youtube channel ID. | |||
if (this.ID.length != "UCyS4xQE6DK4_p3qXQwJQAyA".length) | |||
return; | |||
// Get channel metadata. | |||
const API = 'https://www.googleapis.com/youtube/v3/channels?'; | |||
const API_Parts = 'part=snippet%2CcontentDetails%2Cstatistics'; | |||
const API_Key = '&key=AIzaSyCuIYkMc5SktlnXRXNaDf2ObX-fQvtWCnQ' | |||
const API_Search_ID = '&id=' + this.ID; | |||
Axios.get(API + API_Parts + API_Search_ID + API_Key).then((resp) => { | |||
this.Description = resp.data.items[0].snippet.description; | |||
this.Title = resp.data.items[0].snippet.title; | |||
this.Thumbnail = resp.data.items[0].snippet.thumbnails.medium.url; | |||
}) | |||
.catch(function (error) { | |||
console.log(error); | |||
}); | |||
} | |||
} | |||
}); | |||
</script> |
@@ -0,0 +1,40 @@ | |||
<template> | |||
<table> | |||
<thead> | |||
<tr> | |||
<th>ID</th> | |||
<th>Title</th> | |||
<th>Description</th> | |||
<th>User Tags</th> | |||
</tr> | |||
</thead> | |||
<tbody> | |||
<tr v-cloak v-for="channel in Channels" :key=channel.ID class="Grid_Small_Text"> | |||
<td>{{channel.ID}}</td> | |||
<td>{{channel.Title}}</td> | |||
<td>{{channel.Description}}</td> | |||
<td>{{channel.User_Tags}}</td> | |||
</tr> | |||
</tbody> | |||
</table> | |||
</template> | |||
<script lang="ts"> | |||
import Vue, {PropOptions} from "vue"; | |||
import * as Dyt from "../dumbyt"; | |||
// Vue class for keeping state of the videos. | |||
export default Vue.extend({ | |||
props: { | |||
Channels: { | |||
type: Array, | |||
} as PropOptions<Dyt.Channel[]>, | |||
} | |||
}); | |||
</script> | |||
<style> | |||
.Grid_Small_Text { | |||
font-size: 0.7em; | |||
} | |||
</style> |
@@ -0,0 +1,45 @@ | |||
<template> | |||
<div> | |||
<input id="searchbox0" class="searchbox" type="text" v-model="searchbox_str" | |||
placeholder="Search String goes here, for example: >5m,c++" | |||
/> | |||
</div> | |||
</template> | |||
<script lang="ts"> | |||
import Vue, {PropOptions} from "vue"; | |||
import * as _ from "lodash"; | |||
import * as Dyt from "../dumbyt"; | |||
// Vue class for keeping state of the videos. | |||
export default Vue.extend({ | |||
data() { | |||
return { | |||
searchbox_str: "", | |||
} | |||
}, | |||
// Manually attaching functions to watchers of data. | |||
watch: { | |||
// Searchbox updater with debouncing. | |||
searchbox_str (s: string) { | |||
var v = _.debounce((s) => { | |||
Dyt.search_videos(s).then(videos => { | |||
this.$emit('search_complete', videos) | |||
}); | |||
}, 400) | |||
v(s); | |||
} | |||
} | |||
}); | |||
</script> | |||
<style scoped> | |||
.searchbox { | |||
font-size: 1.2em; | |||
max-width: 40em; | |||
margin-left: auto; | |||
margin-right: auto; | |||
margin-top: 2em; | |||
} | |||
</style> |
@@ -0,0 +1,52 @@ | |||
<template> | |||
<div> | |||
<div class="grid-x large-up-6"> | |||
<div v-for="video in Videos" :key="video.ID" class="cell"> | |||
<div class="card ytcard"> | |||
<a :href="video.URL"><img :src="video.Thumbnail"></a> | |||
<div class="card-section description"> | |||
<div class="descriptiontitle">{{ video.Title }}</div> | |||
<div class="descriptionchannel">{{ video.Channel }}</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div > | |||
</div> | |||
</template> | |||
<script lang="ts"> | |||
import Vue, {PropOptions} from "vue"; | |||
import * as Dyt from "../dumbyt"; | |||
// Vue class for keeping state of the videos. | |||
export default Vue.extend({ | |||
props: { | |||
Videos: { | |||
type: Array, | |||
} as PropOptions<Dyt.Video[]>, | |||
} | |||
}); | |||
</script> | |||
<style scoped> | |||
.ytcard { | |||
border-bottom-left-radius: 10px; | |||
border-bottom-right-radius: 10px; | |||
background-color:#ddd9d4; | |||
margin: 6px 2px 6px; | |||
} | |||
.description { | |||
padding: 7px 5px 7px; | |||
} | |||
.descriptiontitle { | |||
font-size: 14px; | |||
padding-bottom:10px; | |||
} | |||
.descriptionchannel { | |||
font-size: 10px; | |||
text-align: right; | |||
} | |||
</style> |
@@ -0,0 +1,43 @@ | |||
<template> | |||
<table> | |||
<thead> | |||
<tr> | |||
<th>ID</th> | |||
<th>Title</th> | |||
<th>Channel</th> | |||
<th>Seconds</th> | |||
<th>Tags</th> | |||
</tr> | |||
</thead> | |||
<tbody> | |||
<tr v-cloak v-for="video in Videos" :key=video.ID class="Grid_Small_Text"> | |||
<td>{{video.ID}}</td> | |||
<td>{{video.Title}}</td> | |||
<td>{{video.Channel}}</td> | |||
<td>{{video.Seconds}}</td> | |||
<td>{{video.Tags}}</td> | |||
</tr> | |||
</tbody> | |||
</table> | |||
</template> | |||
<script lang="ts"> | |||
import Vue, {PropOptions} from "vue"; | |||
import * as Dyt from "../dumbyt"; | |||
// Vue class for keeping state of the videos. | |||
export default Vue.extend({ | |||
props: { | |||
Videos: { | |||
type: Array, | |||
} as PropOptions<Dyt.Video[]>, | |||
} | |||
}); | |||
</script> | |||
<style> | |||
.Grid_Small_Text { | |||
font-size: 0.7em; | |||
} | |||
</style> | |||
@@ -0,0 +1,210 @@ | |||
import Axios from "axios"; | |||
// Base URL for API queries. | |||
const API_BASE_URL = "/api"; | |||
// How many chars at most for the description. | |||
const max_description_length = 100; | |||
// Wrapper for channels returned from our server. | |||
export class Channel { | |||
// Title of the channel according to youtube. | |||
public Title: string; | |||
// Description of channel according to youtube. | |||
public Description: string; | |||
// Youtube ID | |||
public ID: string; | |||
// Tags given to the video. | |||
public User_Tags: Array<string>; | |||
// ID's belonging to the channel. | |||
public Video_IDs: Array<string>; | |||
// Popuplate this using data from our server. | |||
constructor(c : any){ | |||
if (c == null) { | |||
this.Title = "NULL" | |||
this.Description = "NULL" | |||
this.ID = "NULL" | |||
this.User_Tags = ["NULL", "NULL", "NULL"] | |||
this.Video_IDs = ["NULL", "NULL"] | |||
} else { | |||
this.Title = c.title; | |||
this.Description = c.description; | |||
this.ID = c.id; | |||
this.User_Tags = c.user_Tags; | |||
this.Video_IDs = c.video_IDs; | |||
} | |||
} | |||
} | |||
// Wrapper for videos returned from our server. | |||
export class Video { | |||
// Title of the video according to youtube. | |||
public Title: string; | |||
// Description of video according to youtube. | |||
public Description: string; | |||
// Youtube ID | |||
public ID: string; | |||
// Thumbnail | |||
public Thumbnail: string; | |||
// What channel made this video. | |||
public Channel: string; | |||
// Duration of the video in seconds. | |||
public Seconds: number; | |||
// Tags relevant to the video. | |||
public Tags: Array<string>; | |||
// Youtube URL of the video. | |||
public URL: string; | |||
// Popuplate this using data from our server. | |||
constructor(v: any){ | |||
if (v == null){ | |||
this.Title = "NULL"; | |||
this.Description = "NULL"; | |||
this.ID = "NULL"; | |||
this.Thumbnail = "NULL"; | |||
this.Channel = "NULL"; | |||
this.Seconds = 9999999; | |||
this.Tags = ["NULL", "NULL"]; | |||
this.URL = "NULL"; | |||
} else { | |||
this.Title = v.title; | |||
this.Description = v.description; | |||
this.ID = v.id; | |||
this.Thumbnail = v.thumbnail; | |||
this.Channel = v.channel; | |||
this.Seconds = v.seconds; | |||
this.Tags = v.tags; | |||
this.URL = "https://www.youtube.com/watch?v=" + v.id; | |||
} | |||
} | |||
} | |||
// Types of modifications which can be applied to various models. | |||
export enum Modification { | |||
Add, Delete, Refresh | |||
} | |||
// Change a channel state. | |||
export async function channel_modify(youtubeID : string, modify : Modification): Promise<void | string> { | |||
switch (modify){ | |||
case Modification.Add: { | |||
let URL = API_BASE_URL + '/Channels/' + youtubeID; | |||
let resp = await Axios.post(URL).catch((error) => { | |||
if (error.response) | |||
return error.response.data; | |||
else if (error.request) | |||
return error.request; | |||
else | |||
return "Axios request failed for unkown reason."; | |||
}); | |||
if (typeof resp == "string") | |||
return resp; | |||
break; | |||
} | |||
case Modification.Delete: { | |||
let URL = API_BASE_URL + '/Channels/' + youtubeID; | |||
let resp = await Axios.delete(URL).catch(e => console.log(e)); | |||
if (resp != null){ | |||
if (resp.status != 200) | |||
return resp.data(); | |||
else | |||
return; | |||
} else { | |||
return "Response is null"; | |||
} | |||
} | |||
case Modification.Refresh: { | |||
let URL = API_BASE_URL + '/Channels/Update/' + youtubeID; | |||
let resp = await Axios.post(URL).catch(e => console.log(e)); | |||
if (resp != null){ | |||
if (resp.status != 200) | |||
return resp.data(); | |||
else | |||
return; | |||
} else { | |||
return "Response is null"; | |||
} | |||
} | |||
default: { | |||
console.log("Unknown request type, error ..."); | |||
break; | |||
} | |||
} | |||
} | |||
// Delete a video. | |||
export async function video_delete(youtubeID : string) : Promise<void> { | |||
let URL = API_BASE_URL + '/Videos/' + youtubeID; | |||
let resp = await Axios.delete(URL).catch(e => console.log(e)); | |||
if ((resp == null) || (resp.data == null)){ | |||
console.log("Video delete via " + URL + " FAIL"); | |||
}; | |||
} | |||
// Search for channels based on a search string. | |||
export async function search_channels(searchstr : string): Promise<Array<Channel>> { | |||
// Temporary holder for data. | |||
let Channels : Array<Channel> = []; | |||
// Ask server for data. | |||
let resp = await Axios.get(API_BASE_URL + '/Channels/' + searchstr).catch(e => console.log(e)); | |||
// Handle all our nulls. | |||
if (resp == null || resp.data == null) | |||
console.log("server /api/Channels/" + searchstr + " return is null"); | |||
else { | |||
// Parse our videos. | |||
resp.data.forEach((c: any) => { | |||
// Trim description if needed. | |||
if (c.description.length > max_description_length) { | |||
c.description = c.description.substring(0, max_description_length) + " ..."; | |||
} | |||
// Add it to our array | |||
Channels.push(new Channel(c)); | |||
}); | |||
} | |||
// Send back the resulting videos. | |||
return Promise.resolve(Channels); | |||
} | |||
// Search for videos based on a search string. | |||
export async function search_videos(searchstr : string): Promise<Array<Video>> { | |||
// Temporary holder for videos. | |||
let Videos : Array<Video> = []; | |||
// Ask server for videos. | |||
let resp = await Axios.get(API_BASE_URL + '/Videos/' + searchstr).catch(e => console.log(e)); | |||
// Handle all our nulls. | |||
if (resp == null || resp.data == null) | |||
console.log("server /videos/ return is null"); | |||
else { | |||
// Parse our videos. | |||
resp.data.forEach((v: any) => { | |||
// Trim description if needed. | |||
if (v.description.length > max_description_length) { | |||
v.description = v.description.substring(0, max_description_length) + " ..."; | |||
} | |||
// Add it to our array | |||
Videos.push(new Video(v)); | |||
}); | |||
} | |||
// Send back the resulting videos. | |||
return Promise.resolve(Videos); | |||
} |
@@ -0,0 +1,15 @@ | |||
body { | |||
margin-left: 5%; | |||
margin-right: 5%; | |||
line-height: 1.6; | |||
font-size: 18px; | |||
color: #003636; | |||
background-color: #F8F8F8; | |||
max-width: 1280px; | |||
margin: auto; | |||
} | |||
.pageheader { | |||
max-width: 960px; | |||
margin: auto; | |||
} |
@@ -0,0 +1,67 @@ | |||
import Vue from "vue"; | |||
import VGC from "./components/Video_Grid.vue"; | |||
import SCH from "./components/Search.vue"; | |||
import VTBL from "./components/Video_Table.vue"; | |||
import CTBL from "./components/Channel_Table.vue"; | |||
import CADD from "./components/Channel_Add.vue"; | |||
import './index.css'; | |||
import { Video, search_videos, Channel, search_channels } from "./dumbyt"; | |||
let MainApp = new Vue({ | |||
el: "#app", | |||
template: ` | |||
<div> | |||
<SCH v-on:search_complete="search_completed"/> | |||
<VGC v-bind:Videos="Videos"/> | |||
</div> | |||
`, | |||
components: { | |||
SCH, | |||
VGC | |||
}, | |||
data() { | |||
return { | |||
Videos: Array<Video>() | |||
} | |||
}, | |||
methods: { | |||
// Callback for when the search component got results back. | |||
search_completed(videos : Array<Video>) : void { | |||
this.Videos = videos; | |||
} | |||
} | |||
}); | |||
let AdminApp = new Vue({ | |||
el: "#admin_app", | |||
template: ` | |||
<div> | |||
<SCH v-on:search_complete="search_completed"/> | |||
<CADD/> | |||
<CTBL v-bind:Channels="Channels"/> | |||
<VTBL v-bind:Videos="Videos"/> | |||
</div> | |||
`, | |||
components: { | |||
SCH, | |||
CADD, | |||
CTBL, | |||
VTBL | |||
}, | |||
data() { | |||
return { | |||
Videos: Array<Video>(), | |||
Channels: Array<Channel>() | |||
} | |||
}, | |||
methods: { | |||
// Callback for when the search component got results back. | |||
search_completed(videos : Array<Video>) : void { | |||
this.Videos = videos; | |||
} | |||
}, | |||
mounted(){ | |||
// Populate the channels field immediatly on start up. | |||
search_channels("").then(channels => this.Channels = channels); | |||
} | |||
}); |
@@ -0,0 +1,4 @@ | |||
declare module "*.vue" { | |||
import Vue from "vue"; | |||
export default Vue; | |||
} |
@@ -0,0 +1,15 @@ | |||
{ | |||
"compilerOptions": { | |||
"outDir": "./built/", | |||
"sourceMap": true, | |||
"strict": true, | |||
"noImplicitReturns": true, | |||
"module": "commonjs", | |||
"moduleResolution": "node", | |||
"target": "es6", | |||
"noImplicitAny": true | |||
}, | |||
"include": [ | |||
"./src/**/*" | |||
] | |||
} |
@@ -0,0 +1,65 @@ | |||
var path = require('path') | |||
var webpack = require('webpack') | |||
module.exports = { | |||
entry: './src/index.ts', | |||
output: { | |||
path: path.resolve(__dirname, './dist'), | |||
publicPath: '/dist/', | |||
filename: 'build.js' | |||
}, | |||
module: { | |||
rules: [ | |||
{ | |||
test: /\.vue$/, | |||
loader: 'vue-loader', | |||
options: { | |||
loaders: { | |||
// Since sass-loader (weirdly) has SCSS as its default parse mode, we map | |||
// the "scss" and "sass" values for the lang attribute to the right configs here. | |||
// other preprocessors should work out of the box, no loader config like this necessary. | |||
'scss': 'vue-style-loader!css-loader!sass-loader', | |||
'sass': 'vue-style-loader!css-loader!sass-loader?indentedSyntax', | |||
} | |||
// other vue-loader options go here | |||
} | |||
}, | |||
{ | |||
test: /\.tsx?$/, | |||
loader: 'ts-loader', | |||
exclude: /node_modules/, | |||
options: { | |||
appendTsSuffixTo: [/\.vue$/], | |||
} | |||
}, | |||
{ | |||
test: /\.(png|jpg|gif|svg)$/, | |||
loader: 'file-loader', | |||
options: { | |||
name: '[name].[ext]?[hash]' | |||
} | |||
}, | |||
{ | |||
test: /\.css$/, | |||
use: [ | |||
'style-loader', | |||
'css-loader' | |||
] | |||
} | |||
] | |||
}, | |||
resolve: { | |||
extensions: ['.ts', '.js', '.vue', '.json'], | |||
alias: { | |||
'vue$': 'vue/dist/vue.esm.js' | |||
} | |||
}, | |||
devServer: { | |||
historyApiFallback: true, | |||
noInfo: true | |||
}, | |||
performance: { | |||
hints: false | |||
}, | |||
devtool: '#eval-source-map' | |||
} |