Browse Source

Initial commit moving front end out of main repo

master
hak8or 3 years ago
commit
3c8f15edac
16 changed files with 11727 additions and 0 deletions
  1. +15
    -0
      admin/index.html
  2. +20
    -0
      index.html
  3. +10969
    -0
      package-lock.json
  4. +31
    -0
      package.json
  5. +10
    -0
      readme.md
  6. +126
    -0
      src/components/Channel_Add.vue
  7. +40
    -0
      src/components/Channel_Table.vue
  8. +45
    -0
      src/components/Search.vue
  9. +52
    -0
      src/components/Video_Grid.vue
  10. +43
    -0
      src/components/Video_Table.vue
  11. +210
    -0
      src/dumbyt.ts
  12. +15
    -0
      src/index.css
  13. +67
    -0
      src/index.ts
  14. +4
    -0
      src/vue-shims.d.ts
  15. +15
    -0
      tsconfig.json
  16. +65
    -0
      webpack.config.js

+ 15
- 0
admin/index.html View File

@@ -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>

+ 20
- 0
index.html View File

@@ -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>

+ 10969
- 0
package-lock.json
File diff suppressed because it is too large
View File


+ 31
- 0
package.json View File

@@ -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"
}
}

+ 10
- 0
readme.md View File

@@ -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.


+ 126
- 0
src/components/Channel_Add.vue View File

@@ -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>

+ 40
- 0
src/components/Channel_Table.vue View File

@@ -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>

+ 45
- 0
src/components/Search.vue View File

@@ -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>

+ 52
- 0
src/components/Video_Grid.vue View File

@@ -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>

+ 43
- 0
src/components/Video_Table.vue View File

@@ -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>


+ 210
- 0
src/dumbyt.ts View File

@@ -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);
}

+ 15
- 0
src/index.css View File

@@ -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;
}

+ 67
- 0
src/index.ts View File

@@ -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);
}
});

+ 4
- 0
src/vue-shims.d.ts View File

@@ -0,0 +1,4 @@
declare module "*.vue" {
import Vue from "vue";
export default Vue;
}

+ 15
- 0
tsconfig.json View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"outDir": "./built/",
"sourceMap": true,
"strict": true,
"noImplicitReturns": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es6",
"noImplicitAny": true
},
"include": [
"./src/**/*"
]
}

+ 65
- 0
webpack.config.js View File

@@ -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'
}

Loading…
Cancel
Save