├── vue
├── .nvmrc
├── .browserlintrc
├── public
│ ├── robots.txt
│ ├── og.png
│ ├── logo.png
│ ├── favicon.png
│ ├── devternity.png
│ ├── logo_dark.png
│ ├── speakers
│ │ ├── jbrains.jpg
│ │ ├── guy_steele.jpg
│ │ ├── russ_olsen.jpg
│ │ ├── ryan_dahl.png
│ │ ├── seriouspony.png
│ │ ├── matt_abrahams.jpg
│ │ └── james_hamilton.png
│ ├── btn_google_signin_light_focus_web@2x.png
│ ├── btn_google_signin_light_normal_web@2x.png
│ ├── btn_google_signin_light_pressed_web@2x.png
│ ├── index.html
│ ├── logo.svg
│ ├── logo_dark.svg
│ ├── privacy.html
│ └── 404.html
├── vue.config.js
├── .babelrc
├── src
│ ├── helpers
│ │ ├── datetime.js
│ │ ├── filters.js
│ │ └── videos.js
│ ├── App.vue
│ ├── api
│ │ └── index.js
│ ├── RelatedVideos.vue
│ ├── WatchingNow.vue
│ ├── Categories.vue
│ ├── TwitterThanks.vue
│ ├── Footer.vue
│ ├── MagicCircle.vue
│ ├── styles
│ │ └── main.scss
│ ├── edit
│ │ └── index.js
│ ├── VideoComments.vue
│ ├── Login.vue
│ ├── user
│ │ └── index.js
│ ├── auth
│ │ └── index.js
│ ├── VideoComment.vue
│ ├── EditVideo.vue
│ ├── VideoCard.vue
│ ├── VideoCommentEditor.vue
│ ├── NavBar.vue
│ ├── Banner.vue
│ ├── main.js
│ ├── Watch.vue
│ ├── VideoActions.vue
│ ├── Search.vue
│ └── SubmitVideo.vue
├── .firebaserc
├── .eslintrc.js
├── firebase.json
└── package.json
├── worker
├── .cargo-ok
├── wrangler.toml
├── package.json
├── package-lock.json
└── index.js
├── express
├── .gcloudignore
├── .gitignore
├── src
│ ├── libs
│ │ ├── Stats.js
│ │ ├── Middlewares.js
│ │ ├── Datastore.js
│ │ ├── Youtube.js
│ │ └── Cookies.js
│ └── routes
│ │ ├── stats.js
│ │ ├── index.js
│ │ ├── tweets.test.js
│ │ ├── rss.js
│ │ ├── og.js
│ │ ├── sitemap.js
│ │ ├── tweets.js
│ │ ├── videos.js
│ │ ├── youtube.js
│ │ ├── user.js
│ │ ├── search.js
│ │ └── auth.js
├── reindex-run.js
├── index.yaml
├── reindex-impl.js
├── index.js
└── package.json
├── .editorconfig
├── .gitignore
├── .vscode
└── settings.json
├── README.md
└── .github
└── workflows
└── server-deploy.yml
/vue/.nvmrc:
--------------------------------------------------------------------------------
1 | v16
2 |
--------------------------------------------------------------------------------
/worker/.cargo-ok:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/vue/.browserlintrc:
--------------------------------------------------------------------------------
1 | > 1%
2 | last 2 versions
3 |
--------------------------------------------------------------------------------
/vue/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Allow: /
3 |
--------------------------------------------------------------------------------
/vue/public/og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/og.png
--------------------------------------------------------------------------------
/vue/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/logo.png
--------------------------------------------------------------------------------
/vue/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/favicon.png
--------------------------------------------------------------------------------
/vue/public/devternity.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/devternity.png
--------------------------------------------------------------------------------
/vue/public/logo_dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/logo_dark.png
--------------------------------------------------------------------------------
/vue/public/speakers/jbrains.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/speakers/jbrains.jpg
--------------------------------------------------------------------------------
/vue/public/speakers/guy_steele.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/speakers/guy_steele.jpg
--------------------------------------------------------------------------------
/vue/public/speakers/russ_olsen.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/speakers/russ_olsen.jpg
--------------------------------------------------------------------------------
/vue/public/speakers/ryan_dahl.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/speakers/ryan_dahl.png
--------------------------------------------------------------------------------
/vue/public/speakers/seriouspony.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/speakers/seriouspony.png
--------------------------------------------------------------------------------
/vue/public/speakers/matt_abrahams.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/speakers/matt_abrahams.jpg
--------------------------------------------------------------------------------
/vue/public/speakers/james_hamilton.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/speakers/james_hamilton.png
--------------------------------------------------------------------------------
/express/.gcloudignore:
--------------------------------------------------------------------------------
1 | .gitignore
2 | yarn*
3 | service_key.json
4 | tsconfig.json
5 | ./src/
6 | *.yml
7 | *.enc
8 | *.ts
9 | .serverless/
--------------------------------------------------------------------------------
/vue/public/btn_google_signin_light_focus_web@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/btn_google_signin_light_focus_web@2x.png
--------------------------------------------------------------------------------
/vue/public/btn_google_signin_light_normal_web@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/btn_google_signin_light_normal_web@2x.png
--------------------------------------------------------------------------------
/vue/public/btn_google_signin_light_pressed_web@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/btn_google_signin_light_pressed_web@2x.png
--------------------------------------------------------------------------------
/vue/vue.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | devServer: {
3 | hot: true,
4 | host: "devtube.xxx",
5 | disableHostCheck: true,
6 | port: 8080
7 | }
8 | };
9 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*]
2 | indent_style = space
3 | indent_size = 2
4 | end_of_line = lf
5 | charset = utf-8
6 | trim_trailing_whitespace = true
7 | insert_final_newline = true
8 |
--------------------------------------------------------------------------------
/express/.gitignore:
--------------------------------------------------------------------------------
1 | !webpack.config.js
2 | !datastore.init.js
3 | *.pem
4 | service_key.json
5 | firebase.json
6 | .env
7 | .serverless/
8 | .build/
9 | dist/
10 | lib/
11 | data/
12 | data.zip
13 |
--------------------------------------------------------------------------------
/worker/wrangler.toml:
--------------------------------------------------------------------------------
1 | name = "worker-devtube"
2 | account_id = "3ef2bbbd14c3c8946513f654cb9889b5"
3 | workers_dev = false
4 | route = "dev.tube/*"
5 | main = "index.js"
6 | compatibility_date = "2022-04-05"
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules/
3 | reindex/
4 | backup/
5 | dist/
6 | src/static/*.xml
7 | service_key.json
8 | datastore_key.json
9 | firebase.config.json
10 | .idea/
11 | .firebase
12 | *.log
13 |
--------------------------------------------------------------------------------
/vue/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@vue/cli-plugin-babel/preset"],
3 | "plugins": [
4 | // "syntax-dynamic-import",
5 | "@babel/plugin-proposal-object-rest-spread",
6 | "@babel/plugin-proposal-optional-chaining"
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/vue/src/helpers/datetime.js:
--------------------------------------------------------------------------------
1 | import dayjs from "dayjs";
2 | import duration from "dayjs/plugin/duration";
3 | import relativeTime from "dayjs/plugin/relativeTime";
4 | dayjs.extend(duration);
5 | dayjs.extend(relativeTime);
6 |
7 | export { dayjs };
8 |
--------------------------------------------------------------------------------
/express/src/libs/Stats.js:
--------------------------------------------------------------------------------
1 | const memoize = require('memoizee');
2 | const { readFileSync } = require("fs");
3 |
4 | const readStats = () => JSON.parse(readFileSync('./data/stats.json', { encoding: "utf8" }));
5 | const statsForever = memoize(readStats, { promise: true });
6 |
7 | module.exports.statsForever = statsForever
--------------------------------------------------------------------------------
/vue/.firebaserc:
--------------------------------------------------------------------------------
1 | {
2 | "projects": {
3 | "default": "dev-tube"
4 | },
5 | "targets": {
6 | "dev-tube": {
7 | "hosting": {
8 | "devtube": [
9 | "dev-tube"
10 | ],
11 | "devtube-api": [
12 | "devtube-api"
13 | ]
14 | }
15 | }
16 | }
17 | }
--------------------------------------------------------------------------------
/worker/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "worker",
4 | "version": "1.0.0",
5 | "description": "A template for kick starting a Cloudflare Workers project",
6 | "main": "index.js",
7 | "author": "Eduards Sizovs",
8 | "license": "MIT",
9 | "dependencies": {},
10 | "volta": {
11 | "node": "18.20.8"
12 | }
13 | }
--------------------------------------------------------------------------------
/express/src/routes/stats.js:
--------------------------------------------------------------------------------
1 | const asyncHandler = require('express-async-handler')
2 | const router = require("express").Router();
3 | const { statsForever } = require("../libs/Stats")
4 |
5 | router.get("/", asyncHandler(async (_req, res) => {
6 | const stats = await statsForever();
7 | res.json(stats);
8 | }));
9 |
10 | module.exports = router;
11 |
--------------------------------------------------------------------------------
/vue/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: {
4 | node: true
5 | },
6 | extends: ["plugin:vue/essential", "@vue/prettier"],
7 | rules: {
8 | "no-console": "off",
9 | "no-debugger": process.env.NODE_ENV === "production" ? "error" : "off"
10 | },
11 | parserOptions: {
12 | parser: "babel-eslint"
13 | }
14 | };
15 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "git.autofetch": true,
3 | "editor.renderControlCharacters": false,
4 | "git.enableSmartCommit": true,
5 | "editor.tabSize": 2,
6 | "editor.insertSpaces": true,
7 | "editor.detectIndentation": false,
8 | "editor.formatOnSave": true,
9 | "vetur.validation.template": false,
10 | "editor.codeActionsOnSave": {
11 | "source.fixAll.eslint": "explicit"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/worker/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "worker",
3 | "version": "1.0.0",
4 | "lockfileVersion": 1,
5 | "requires": true,
6 | "dependencies": {
7 | "prettier": {
8 | "version": "1.19.1",
9 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz",
10 | "integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==",
11 | "dev": true
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/vue/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 | .app
3 | Login
4 | notifications(group="notification", :duration="-1")
5 | Banner
6 | NavBar
7 | router-view
8 | Footer
9 |
10 |
20 |
--------------------------------------------------------------------------------
/express/src/libs/Middlewares.js:
--------------------------------------------------------------------------------
1 | const returnBack = (req, _res, next) => {
2 | req.session.returnTo = req.query.returnTo
3 | next();
4 | }
5 |
6 | const authenticated = (req, res, next) => {
7 | if (!req.user) {
8 | res.sendStatus(403)
9 | return;
10 | }
11 | next();
12 | }
13 |
14 | const adminsOnly = (req, res, next) => {
15 | if (!req.user || !req.user.admin) {
16 | res.sendStatus(403)
17 | return;
18 | }
19 | next();
20 | }
21 |
22 | module.exports.authenticated = authenticated;
23 | module.exports.returnBack = returnBack;
24 | module.exports.adminsOnly = adminsOnly;
25 |
26 |
--------------------------------------------------------------------------------
/express/src/routes/index.js:
--------------------------------------------------------------------------------
1 | function initRoute(app, route, requirePath) {
2 | console.time(route + " init")
3 | app.use(route, require(requirePath));
4 | console.timeEnd(route + " init")
5 | }
6 |
7 | module.exports = app => {
8 | initRoute(app, "/api/s", "./search");
9 | initRoute(app, "/api/videos", "./videos");
10 | initRoute(app, "/api/auth", "./auth");
11 | initRoute(app, "/api/stats", "./stats");
12 | initRoute(app, "/api/sitemap", "./sitemap");
13 | initRoute(app, "/api/rss", "./rss");
14 | initRoute(app, "/api/og", "./og");
15 | initRoute(app, "/api/user", "./user");
16 | initRoute(app, "/api/tweets", "./tweets");
17 | initRoute(app, "/api/youtube", "./youtube");
18 | };
--------------------------------------------------------------------------------
/express/reindex-run.js:
--------------------------------------------------------------------------------
1 | (async () => {
2 | const { processVideos } = require("./src/libs/Datastore")
3 | const { begin, each, end } = require("./reindex-impl")
4 | const { writeFileSync } = require("fs");
5 |
6 | const data = begin();
7 | const approvedVideos = []
8 | const mapper = (video) => {
9 | const newVideo = each(video, data);
10 | if (newVideo.status === 'approved') {
11 | approvedVideos.push(newVideo)
12 | }
13 |
14 | }
15 | const done = () => {
16 | const stats = end(data);
17 | writeFileSync('./data/stats.json', JSON.stringify(stats));
18 | writeFileSync('./data/videos.json', JSON.stringify(approvedVideos));
19 | }
20 |
21 | processVideos(mapper, done);
22 | })();
23 |
24 |
25 |
--------------------------------------------------------------------------------
/vue/src/helpers/filters.js:
--------------------------------------------------------------------------------
1 | import { dayjs } from "./datetime";
2 |
3 | export const durationFull = it => {
4 | if (!it) {
5 | return it;
6 | }
7 |
8 | const videoDuration = dayjs.duration(it);
9 | const hours = videoDuration.hours();
10 | const minutes = videoDuration.minutes();
11 | return hours ? `${hours} hour(s)` : `${minutes} minutes`;
12 | };
13 |
14 | export const published = it => (it ? dayjs(it).format("MMM YYYY") : it);
15 | export const year = it => (it ? dayjs(it).format("YYYY") : it);
16 |
17 | export const years = () => {
18 | const yearNow = dayjs().year();
19 | const yearNext = dayjs()
20 | .add(1, "year")
21 | .year();
22 | return `${yearNow}/${yearNext}`;
23 | };
24 |
25 | export const ago = it => {
26 | return dayjs(it).fromNow();
27 | };
28 |
--------------------------------------------------------------------------------
/vue/src/api/index.js:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import Vue from "vue";
3 |
4 | export const bucket = axios.create({
5 | baseURL: "//storage.googleapis.com/dev-tube-index"
6 | });
7 |
8 | export const apiUrl = window.location.href.includes("devtube.xxx")
9 | ? "//devtube.xxx:8100/api/"
10 | : "//dev.tube/api/";
11 |
12 | export const api = axios.create({
13 | baseURL: apiUrl,
14 | timeout: 10000,
15 | withCredentials: true
16 | });
17 |
18 | api.interceptors.response.use(
19 | response => response,
20 | error => {
21 | Vue.notify({
22 | group: "notification",
23 | title: "Oops! Something went wrong.",
24 | type: "error",
25 | text: "Please see logs and submit an issue: bit.ly/devtube-issue"
26 | });
27 | return Promise.reject(error);
28 | }
29 | );
30 |
--------------------------------------------------------------------------------
/express/index.yaml:
--------------------------------------------------------------------------------
1 | indexes:
2 | - kind: videos
3 | properties:
4 | - name: status
5 | - name: likes
6 | direction: desc
7 |
8 | - kind: videos
9 | properties:
10 | - name: topics
11 | - name: likes
12 | direction: desc
13 |
14 | - kind: videos
15 | properties:
16 | - name: speakerTwitters
17 | - name: likes
18 | direction: desc
19 |
20 | - kind: videos
21 | properties:
22 | - name: status
23 | - name: submissionDate
24 | direction: desc
25 |
26 | - kind: videos
27 | properties:
28 | - name: topics
29 | - name: submissionDate
30 | direction: desc
31 |
32 | - kind: videos
33 | properties:
34 | - name: speakerTwitters
35 | - name: submissionDate
36 | direction: desc
37 |
--------------------------------------------------------------------------------
/worker/index.js:
--------------------------------------------------------------------------------
1 | addEventListener('fetch', event => {
2 | event.respondWith(handleRequest(event.request))
3 | })
4 |
5 | async function handleRequest(request) {
6 | const { url, headers } = request
7 |
8 | const userAgent = headers.get('user-agent') || ''
9 | const bots = ["Twitterbot", "Slackbot", "LinkedIn"];
10 |
11 | if (url.includes('https://dev.tube/video/') && bots.some(bot => userAgent.includes(bot))) {
12 | const redirectUrl = url.replace('https://dev.tube/video/', 'https://dev.tube/api/og/')
13 | return Response.redirect(redirectUrl, 302)
14 | }
15 |
16 | if (url.includes('dev.tube/sitemap.xml')) {
17 | return Response.redirect('https://dev.tube/api/sitemap', 302)
18 | } else if (url.includes('dev.tube/rss.xml')) {
19 | return Response.redirect('https://dev.tube/api/rss', 302)
20 | } else {
21 | return await fetch(request)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/express/src/routes/tweets.test.js:
--------------------------------------------------------------------------------
1 | const Twit = require("twit");
2 | const { tweetTrending } = require("./tweets");
3 |
4 | jest.mock("Twit");
5 |
6 | const [twitter] = Twit.mock.instances;
7 | twitter.post
8 | .mockName("post")
9 | .mockReturnValue(Promise.resolve("OK"));
10 |
11 | afterEach(() => {
12 | jest.clearAllMocks();
13 | });
14 |
15 |
16 | test("tweet a trending video", async () => {
17 | const video = {
18 | "likes": 1501,
19 | "objectID": "AEtCEt44vlE",
20 | "speakerTwitters": [
21 | "eduardsi"
22 | ],
23 | "title": "Beyond Software Craftsmanship"
24 | }
25 |
26 | const watchingNow = 10
27 | const comments = 3400
28 | await tweetTrending(video, watchingNow, comments);
29 | expect(twitter.post).toHaveBeenCalledWith("statuses/update", {
30 | status: `@eduardsi:
31 | 📈 Your talk "Beyond Software Craftsmanship" is trending on DevTube:
32 | ❤️ 1K likes
33 | ✍️ 3K comments
34 | 📺 10 watching now
35 |
36 | https://dev.tube/video/AEtCEt44vlE`,
37 | })
38 | });
--------------------------------------------------------------------------------
/vue/firebase.json:
--------------------------------------------------------------------------------
1 | {
2 | "hosting": [
3 | {
4 | "target": "devtube",
5 | "public": "dist",
6 | "ignore": [
7 | "firebase.json",
8 | "**/.*",
9 | "**/node_modules/**"
10 | ],
11 | "rewrites": [
12 | {
13 | "source": "/api/**",
14 | "function": "api"
15 | },
16 | {
17 | "source": "**",
18 | "destination": "/index.html"
19 | }
20 | ],
21 | "headers": [
22 | {
23 | "source": "**/*.@(js|css|png)",
24 | "headers": [
25 | {
26 | "key": "Cache-Control",
27 | "value": "max-age=31536000"
28 | }
29 | ]
30 | }
31 | ]
32 | },
33 | {
34 | "target": "devtube-api",
35 | "public": "dist",
36 | "ignore": [
37 | "**"
38 | ],
39 | "rewrites": [
40 | {
41 | "source": "**",
42 | "function": "api"
43 | }
44 | ]
45 | }
46 | ]
47 | }
--------------------------------------------------------------------------------
/vue/src/helpers/videos.js:
--------------------------------------------------------------------------------
1 | import dayjs from "dayjs";
2 | import relativeTime from "dayjs/plugin/relativeTime";
3 | import utc from "dayjs/plugin/utc";
4 | dayjs.extend(utc);
5 | dayjs.extend(relativeTime);
6 |
7 | export function avatar(video, speakerIndex) {
8 | const customAvatars = {
9 | seriouspony: "/speakers/seriouspony.png",
10 | spchteach: "/speakers/matt_abrahams.jpg",
11 | jrhatmvdirona: "/speakers/james_hamilton.png",
12 | guysteele: "/speakers/guy_steele.jpg",
13 | ryanmdahl: "/speakers/ryan_dahl.png",
14 | jbrains: "/speakers/jbrains.jpg",
15 | russolsen: "/speakers/russ_olsen.jpg"
16 | };
17 | const speaker = video.speakerTwitters[speakerIndex];
18 | return customAvatars[speaker] || `https://unavatar.io/twitter/${speaker}`;
19 | }
20 |
21 | export function addedAgo(video) {
22 | return "added " + dayjs(video.submissionDate).fromNow();
23 | }
24 |
25 | export function expiresIn() {
26 | const expires = dayjs
27 | .utc()
28 | .endOf("week")
29 | .fromNow();
30 | if (expires.includes("ago")) {
31 | return "soon";
32 | } else {
33 | return expires;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/express/reindex-impl.js:
--------------------------------------------------------------------------------
1 | module.exports.begin = () => {
2 | const data = {
3 | speakerStats: {},
4 | speakerNames: {},
5 | topicStats: {},
6 | karma: {}
7 | }
8 | return data;
9 | }
10 |
11 | module.exports.each = (video, data) => {
12 | data.karma[video.contributor] = (data.karma[video.contributor] || 0) + (video.likes || 1)
13 | video.speakerTwitters.forEach((twitter, index) => {
14 | data.speakerStats[twitter] = (data.speakerStats[twitter] || 0) + 1
15 | data.speakerNames[twitter] = video.speakerNames[index]
16 | })
17 |
18 | video.topics.forEach(topic => {
19 | data.topicStats[topic] = (data.topicStats[topic] || 0) + 1
20 | })
21 |
22 | return video;
23 | };
24 |
25 | module.exports.end = (data) => {
26 |
27 | const topics = Object.entries(data.topicStats).map(([key, count]) => ({ key, count }));
28 | topics.sort((a, b) => a.key.localeCompare(b.key))
29 |
30 |
31 | const speakers = Object.entries(data.speakerStats).map(([key, count]) => ({ key, count, name: data.speakerNames[key] }))
32 | speakers.sort((a, b) => a.name.localeCompare(b.key))
33 |
34 | const stats = {
35 | topics,
36 | speakers,
37 | karma: data.karma
38 | }
39 |
40 | return stats;
41 | }
--------------------------------------------------------------------------------
/express/src/routes/rss.js:
--------------------------------------------------------------------------------
1 | const asyncHandler = require("express-async-handler");
2 | const router = require("express").Router();
3 | const { searchApprovedVideosForever } = require("../libs/Datastore");
4 |
5 | router.get(
6 | "/",
7 | asyncHandler(async (req, res) => {
8 | console.time("rss fetch");
9 | const videos = await searchApprovedVideosForever();
10 | console.timeEnd("rss fetch");
11 |
12 | const feedTitle = "Best tech talks for developers";
13 |
14 | const toFeedItem = (video) => ({
15 | title: video.title,
16 | id: video.objectID,
17 | link: `https://dev.tube/video/${video.objectID}`,
18 | description: `${video.title} by ${video.speakerNames.join(", ")}`,
19 | image: `https://img.youtube.com/vi/${video.objectID}/hqdefault.jpg`,
20 | });
21 |
22 | const { Feed } = require("feed");
23 | const feed = new Feed({
24 | title: feedTitle,
25 | description: feedTitle,
26 | link: "https://dev.tube",
27 | image: `https://dev.tube/favicon.png`,
28 | });
29 | videos.map(toFeedItem).forEach(feed.addItem);
30 |
31 | res.set("Content-Type", "text/xml");
32 | res.send(feed.rss2());
33 | })
34 | );
35 |
36 | module.exports = router;
--------------------------------------------------------------------------------
/vue/src/RelatedVideos.vue:
--------------------------------------------------------------------------------
1 |
2 | .related-videos
3 | .columns.is-multiline.is-mobile
4 | VideoCard.is-12(v-for="video in hits"
5 | :video="video"
6 | :key="video.objectID"
7 | )
8 |
9 |
45 |
--------------------------------------------------------------------------------
/vue/src/WatchingNow.vue:
--------------------------------------------------------------------------------
1 |
2 | span.has-text-weight-bold.tag.is-transparent(v-if="watchingNow" v-bind:class="{ 'has-text-grey' : !darkMode }")
3 | span.has-text-primary(style="font-size: 5px;")
4 | font-awesome-icon.has-text-primary(:icon="['fa', 'circle']")
5 | span.ml-2 {{watchingNow}} watching now
6 |
7 |
8 |
38 |
--------------------------------------------------------------------------------
/vue/src/Categories.vue:
--------------------------------------------------------------------------------
1 |
2 | .container
3 | h2.title.is-5 Topics
4 | .item.is-size-7.mb-1(v-for="topic in allTopics")
5 | router-link.has-text-grey.has-text-weight-bold(:to="'/~' + encodeURIComponent(topic.key)") {{topic.key}}
6 | |
7 | span.tag.is-small.is-light {{topic.count}}
8 | br
9 | br
10 | h2.title.is-5 Speakers
11 | .item.is-size-7.mb-1(v-for="speaker in allSpeakers")
12 | router-link.has-text-grey.has-text-weight-bold(:to="'/@' + speaker.key") {{speaker.name}}
13 | |
14 | span.tag.is-small.is-light {{speaker.count}}
15 |
16 |
34 |
45 |
--------------------------------------------------------------------------------
/vue/src/TwitterThanks.vue:
--------------------------------------------------------------------------------
1 |
2 | a.button(
3 | :href="'//twitter.com/intent/tweet?text=' + text + '&url=' + url + '&via=WatchDevTube'",
4 | target="_blank",
5 | aria-label="twitter"
6 | )
7 | span.icon
8 | font-awesome-icon.has-text-info(:icon="['fab', 'twitter']")
9 | span great talk!
10 |
11 |
47 |
--------------------------------------------------------------------------------
/vue/src/Footer.vue:
--------------------------------------------------------------------------------
1 |
2 | footer.footer.has-background-white
3 | .content.has-text-centered.is-size-6
4 | p
5 | | Coded at night under caffeine by
6 | a(href="https://twitter.com/eduardsi" rel="nofollow noopener noreferrer") @eduardsi
7 | | . No ads, no tracking, open source.
8 | p.is-size-5
9 | a.mr-3.has-text-grey-dark(href='https://github.com/watch-devtube/web' rel="nofollow noopener noreferrer"): font-awesome-icon(:icon="['fab', 'github']" title="Our GitHub")
10 | a.mr-3.has-text-grey-dark(href='https://twitter.com/watchdevtube' rel="nofollow noopener noreferrer"): font-awesome-icon(:icon="['fab', 'twitter']" title="Our Twitter")
11 | a.mr-3.has-text-grey-dark(href='mailto:hello@dev.tube'): font-awesome-icon(:icon="['fa', 'envelope']" title="Our Email")
12 | a.mr-3.has-text-grey-dark(href='https://dev.tube/rss.xml'): font-awesome-icon(:icon="['fa', 'square-rss']" title="Subscribe to RSS feed")
13 | p.has-text-grey-light(style='font-size: 9px;')
14 | | We use cookies to improve UX – sorting pref, user session, night mode. To inspect and block cookies use
15 | a(href='//ghostery.com') Ghostery
16 | | on your browser. Read our
17 | a(href='/privacy.html') privacy policy
18 | | .
19 |
20 |
21 |
--------------------------------------------------------------------------------
/express/src/routes/og.js:
--------------------------------------------------------------------------------
1 | const asyncHandler = require("express-async-handler");
2 | const router = require("express").Router();
3 | const { oneVideo } = require("../libs/Datastore");
4 |
5 | router.get(
6 | "/:videoId",
7 | asyncHandler(async (req, res) => {
8 | const { videoId } = req.params;
9 | if (!videoId) {
10 | res.sendStatus(500)
11 | return;
12 | }
13 |
14 | const video = await oneVideo(videoId);
15 | const ogLink = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`
16 |
17 | const markup = `
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | `;
32 | res.send(markup)
33 |
34 | })
35 | );
36 |
37 | module.exports = router;
38 |
--------------------------------------------------------------------------------
/vue/src/MagicCircle.vue:
--------------------------------------------------------------------------------
1 |
2 | span
3 | span.circle(:style="'width:' + width + 'px; height:' + width + 'px'")
4 | figure(:class="'image is-' + width + 'x' + width")
5 | slot
6 |
7 |
17 |
51 |
--------------------------------------------------------------------------------
/vue/src/styles/main.scss:
--------------------------------------------------------------------------------
1 | $info: #4988ca;
2 | $link: #4988ca;
3 |
4 | .select::after {
5 | border-color: hsl(0, 0%, 86%) !important;
6 | }
7 |
8 | .select select {
9 | border: none !important;
10 | }
11 |
12 | .button.is-text {
13 | background: none !important;
14 | }
15 |
16 | .is-borderless {
17 | border: none !important;
18 | }
19 |
20 | .is-transparent {
21 | background-color: transparent !important;
22 | }
23 |
24 | $card-radius: 25px !important;
25 | .card {
26 | background-color: black !important;
27 | padding: 3rem !important;
28 | margin-bottom: 3rem !important;
29 | }
30 |
31 | .window {
32 | margin-top: 5rem !important;
33 | background: white !important;
34 | border-radius: 10px !important;
35 | padding: 4.5rem !important;
36 | }
37 |
38 | @import "~bulma";
39 | @import "vue-loading-overlay/dist/vue-loading";
40 |
41 | .clearfix {
42 | @include clearfix;
43 | }
44 |
45 | @include mobile {
46 | .card {
47 | padding: 2rem !important;
48 | }
49 | }
50 |
51 | @include mobile {
52 | .window {
53 | padding: 2.5rem !important;
54 | }
55 | }
56 |
57 | .is-loadable {
58 | &.is-loading {
59 | position: relative;
60 | pointer-events: none;
61 | opacity: 0.5;
62 | &:after {
63 | @include loader;
64 | position: absolute;
65 | top: calc(50% - 2.5em);
66 | left: calc(50% - 2.5em);
67 | width: 5em;
68 | height: 5em;
69 | border-width: 0.25em;
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/vue/src/edit/index.js:
--------------------------------------------------------------------------------
1 | import { api } from "../api";
2 |
3 | const state = {
4 | popupVisible: false,
5 | video: undefined,
6 | speakers: []
7 | };
8 |
9 | const getters = {
10 | isPopupVisible: state => state.popupVisible
11 | };
12 |
13 | const actions = {
14 | saveVideo({ state, dispatch }, { tweet }) {
15 | const newVideo = JSON.parse(state.video);
16 | const videoId = newVideo.objectID;
17 | api
18 | .put("/videos/" + videoId, { newVideo, tweet })
19 | .then(() => dispatch("hidePopup"));
20 | },
21 | showPopup({ commit }, videoId) {
22 | api.get("/videos/" + videoId + "/edit").then(({ data }) => {
23 | commit("editReady", data);
24 | commit("popupVisible", true);
25 | const classes = document.documentElement.classList;
26 | classes?.add("is-clipped");
27 | });
28 | },
29 | hidePopup({ commit }) {
30 | commit("popupVisible", false);
31 | const classes = document.documentElement.classList;
32 | classes?.remove("is-clipped");
33 | }
34 | };
35 |
36 | const mutations = {
37 | editVideo: (state, data) => {
38 | state.video = data;
39 | },
40 | editReady: (state, { video, speakers }) => {
41 | state.video = video;
42 | state.speakers = speakers;
43 | },
44 | popupVisible: (state, visible) => {
45 | state.popupVisible = visible;
46 | }
47 | };
48 |
49 | export default {
50 | namespaced: true,
51 | state,
52 | getters,
53 | actions,
54 | mutations
55 | };
56 |
--------------------------------------------------------------------------------
/express/src/routes/sitemap.js:
--------------------------------------------------------------------------------
1 | console.time("initializing sitemap");
2 | const asyncHandler = require("express-async-handler");
3 | const router = require("express").Router();
4 | const { searchApprovedVideosForever } = require("../libs/Datastore");
5 | console.timeEnd("initializing sitemap");
6 |
7 | router.get(
8 | "/",
9 | asyncHandler(async (_req, res) => {
10 | const { SitemapStream } = require("sitemap");
11 | const { createGzip } = require("zlib");
12 |
13 | res.header("Content-Type", "application/xml");
14 | res.header("Content-Encoding", "gzip");
15 |
16 | const links = new Set();
17 | const videos = await searchApprovedVideosForever();
18 |
19 | videos.forEach((video) => {
20 | links.add("/video/" + video.objectID);
21 | video.topics.forEach((topic) => {
22 | links.add("/~" + encodeURIComponent(topic));
23 | });
24 | video.speakerTwitters.forEach((speaker) => {
25 | links.add("/@" + speaker);
26 | });
27 | });
28 |
29 | try {
30 | const stream = new SitemapStream({ hostname: "https://dev.tube/" });
31 | const pipeline = stream.pipe(createGzip());
32 | links.forEach((link) => stream.write({ url: link, changefreq: "daily" }));
33 | stream.end();
34 | pipeline.pipe(res).on("error", (e) => {
35 | throw e;
36 | });
37 | } catch (e) {
38 | console.error(e);
39 | res.status(500).end();
40 | }
41 | })
42 | );
43 |
44 | module.exports = router;
--------------------------------------------------------------------------------
/express/index.js:
--------------------------------------------------------------------------------
1 | const dnsCache = require("dnscache");
2 | const isDevMode = process.env.MODE === "dev"
3 | require('dotenv').config()
4 |
5 | console.time("Cold start")
6 |
7 | dnsCache({
8 | enable: true,
9 | ttl: 300,
10 | cachesize: 1000,
11 | });
12 |
13 | const express = require("express");
14 | const morgan = require('morgan');
15 | const passport = require("passport");
16 | const body = require("body-parser");
17 | const cors = require("cors");
18 | const cookieSession = require("./src/libs/Cookies");
19 | const app = express();
20 | const port = process.env.PORT || 8100;
21 |
22 | app.use(cookieSession({
23 | name: '__session', // limitation because Firebase only supports that cookie name
24 | signed: false, // limitation because Firebase only supports __session cookie, no __session.sig.
25 | domain: isDevMode ? '.devtube.xxx' : '.dev.tube',
26 | secret: process.env.COOKIE_SECRET,
27 | }))
28 | app.use(passport.initialize());
29 | app.use(passport.session());
30 |
31 | app.set("port", port);
32 | app.use(cors({
33 | credentials: true,
34 | origin: isDevMode ? true : 'https://dev.tube'
35 | }));
36 | app.use(body.json());
37 | app.use(morgan('tiny'))
38 |
39 |
40 | console.time("Routes init")
41 | require("./src/routes/index")(app);
42 | console.timeEnd("Routes init")
43 |
44 | console.timeEnd("Cold start")
45 |
46 | if (isDevMode) {
47 | const listener = app.listen(port, () => {
48 | console.log("Your app is listening on port " + listener.address().port);
49 | });
50 | }
51 |
52 | exports.api = app;
53 |
--------------------------------------------------------------------------------
/express/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dev-tube-express",
3 | "description": "Dev.Tube Web Express App",
4 | "version": "1.0.0",
5 | "author": "Eduards Sizovs ",
6 | "main": "index.js",
7 | "private": true,
8 | "scripts": {
9 | "dev": "export GOOGLE_APPLICATION_CREDENTIALS=../datastore_key.json && MODE=dev nodemon index.js",
10 | "devReindex": "export GOOGLE_APPLICATION_CREDENTIALS=../datastore_key.json && node reindex-run.js",
11 | "reindex": "node reindex-run.js",
12 | "test": "jest"
13 | },
14 | "dependencies": {
15 | "@google-cloud/datastore": "^6.6.2",
16 | "axios": "^0.18.0",
17 | "body-parser": "^1.18.3",
18 | "cookie-session": "^2.0.0",
19 | "cors": "^2.8.4",
20 | "dayjs": "^1.11.2",
21 | "dnscache": "^1.0.2",
22 | "dotenv": "^16.0.0",
23 | "express": "^4.16.4",
24 | "express-async-handler": "^1.2.0",
25 | "feed": "^4.2.2",
26 | "googleapis": "^100.0.0",
27 | "joi": "^17.6.0",
28 | "json-diff": "^0.7.4",
29 | "lodash": "^4.17.21",
30 | "memoizee": "^0.4.15",
31 | "morgan": "^1.10.0",
32 | "passport": "^0.5.2",
33 | "passport-github2": "^0.1.12",
34 | "passport-google-oauth2": "^0.2.0",
35 | "passport-twitter": "^1.0.4",
36 | "sitemap": "^7.1.1",
37 | "twit": "^2.2.11"
38 | },
39 | "devDependencies": {
40 | "jest": "^28.1.0",
41 | "nodemon": "^2.0.15"
42 | },
43 | "jest": {
44 | "testPathIgnorePatterns": [
45 | "tmp"
46 | ],
47 | "testMatch": [
48 | "**/**.test.js"
49 | ]
50 | },
51 | "volta": {
52 | "node": "18.20.8"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/vue/src/VideoComments.vue:
--------------------------------------------------------------------------------
1 |
2 | .comments.is-size-7(v-if="commentsEnabled")
3 | VideoCommentEditor(:replyUrl="'/youtube/' + video.objectID + '/comments'" @commented="newCommentAdded")
4 | VideoComment(v-for="comment in comments" :key="comment.id" :comment="comment" :video="video")
5 | section.section
6 | button.button.is-small(v-if="nextPageToken" @click="fetchMoreComments()") More
7 |
8 |
54 |
--------------------------------------------------------------------------------
/vue/src/Login.vue:
--------------------------------------------------------------------------------
1 |
2 | .modal(v-bind:class="{'is-active': isPopupVisible}")
3 | .modal-background
4 | .modal-content
5 | .columns.is-mobile
6 | .column.is-8.is-offset-2.window
7 | .columns
8 | .column.has-text-centered
9 | h1.title.has-text-weight-bold.is-4 Log in to DevTube
10 | p.description ...and unlock some superpowers
11 | .form
12 | br
13 | button.button.is-info.is-fullwidth.is-medium(@click="login('github')")
14 | span.icon
15 | font-awesome-icon(:icon="['fab', 'github']")
16 | span Github
17 | br
18 | button.button.is-info.is-fullwidth.is-medium(@click="login('google')")
19 | span.icon
20 | font-awesome-icon(:icon="['fab', 'google']")
21 | span Google
22 | br
23 | small
24 | em
25 | font-awesome-icon.has-text-danger(:icon="['far', 'heart']")
26 | | We use your email just to know it's you. We won't email you anything. Ever.
27 | .modal-close.is-large(aria-label="close" @click="hidePopup()")
28 |
29 |
39 |
50 |
--------------------------------------------------------------------------------
/vue/src/user/index.js:
--------------------------------------------------------------------------------
1 | import { api } from "../api";
2 |
3 | const state = {
4 | favorites: [],
5 | watched: [],
6 | later: [],
7 | subscribedToWeekly: false
8 | };
9 |
10 | const actions = {
11 | bootstrap({ commit }) {
12 | api.get("/user/bootstrap").then(({ data }) => commit("bootstrap", data));
13 | },
14 | async add({ commit }, { list, videoID }) {
15 | return api
16 | .post("/user/lists/" + list + "/" + videoID)
17 | .then(({ data }) => commit("updated", { list, data }));
18 | },
19 | async remove({ commit }, { list, videoID }) {
20 | return api
21 | .delete("/user/lists/" + list + "/" + videoID)
22 | .then(({ data }) => commit("updated", { list, data }));
23 | },
24 | async subscribeWeekly({ commit }) {
25 | return api
26 | .post("/user/weekly-subscription")
27 | .then(() => commit("subscribedWeekly", true));
28 | },
29 | async unsubscribeWeekly({ commit }) {
30 | return api
31 | .delete("/user/weekly-subscription")
32 | .then(() => commit("subscribedWeekly", false));
33 | }
34 | };
35 |
36 | const mutations = {
37 | subscribedWeekly: (state, yesNo) => {
38 | state.subscribedToWeekly = yesNo;
39 | },
40 | updated: (state, { list, data }) => {
41 | state[list] = [...data];
42 | },
43 | bootstrap: (
44 | state,
45 | { favorites = [], watched = [], later = [], subscribedToWeekly = false }
46 | ) => {
47 | state.favorites = favorites;
48 | state.watched = watched;
49 | state.later = later;
50 | state.subscribedToWeekly = subscribedToWeekly;
51 | }
52 | };
53 |
54 | export default {
55 | namespaced: true,
56 | state,
57 | actions,
58 | mutations
59 | };
60 |
--------------------------------------------------------------------------------
/vue/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | The best tech talks for developers
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/vue/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dev-tube-web",
3 | "description": "Dev.Tube Web",
4 | "version": "1.0.0",
5 | "author": "Eduards Sizovs ",
6 | "private": true,
7 | "scripts": {
8 | "lint": "vue-cli-service lint",
9 | "dev": "vue-cli-service serve",
10 | "build": "vue-cli-service build --report"
11 | },
12 | "dependencies": {
13 | "@fortawesome/fontawesome-svg-core": "^6.1.1",
14 | "@fortawesome/free-brands-svg-icons": "^6.1.1",
15 | "@fortawesome/free-regular-svg-icons": "^6.1.1",
16 | "@fortawesome/free-solid-svg-icons": "^6.1.1",
17 | "@fortawesome/vue-fontawesome": "^2.0.6",
18 | "axios": "^0.19.0",
19 | "bulma": "^0.9.4",
20 | "dayjs": "^1.5.16",
21 | "parse-url": "^5.0.7",
22 | "plausible-tracker": "^0.3.9",
23 | "vue": "^2.7.14",
24 | "vue-head": "^2.2.0",
25 | "vue-loading-overlay": "^3.0.1",
26 | "vue-notification": "^1.3.12",
27 | "vue-observe-visibility": "^1.0.0",
28 | "vue-router": "^3.0.1",
29 | "vuex": "^3.0.1"
30 | },
31 | "devDependencies": {
32 | "@babel/plugin-proposal-object-rest-spread": "^7.9.5",
33 | "@babel/plugin-proposal-optional-chaining": "^7.8.3",
34 | "@vue/cli-plugin-babel": "^4.1.0",
35 | "@vue/cli-plugin-eslint": "^4.1.0",
36 | "@vue/cli-plugin-router": "^4.1.0",
37 | "@vue/cli-plugin-vuex": "^4.1.0",
38 | "@vue/cli-service": "^4.1.0",
39 | "@vue/eslint-config-prettier": "^5.0.0",
40 | "babel-eslint": "^10.0.3",
41 | "eslint": "^5.16.0",
42 | "eslint-plugin-prettier": "^3.1.1",
43 | "eslint-plugin-vue": "^5.0.0",
44 | "sass": "^1.86.0",
45 | "prettier": "^1.19.1",
46 | "pug": "^2.0.4",
47 | "pug-plain-loader": "^1.0.0",
48 | "sass-loader": "^10.2.1",
49 | "vue-template-compiler": "^2.7.14"
50 | },
51 | "volta": {
52 | "node": "16.20.2"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/vue/src/auth/index.js:
--------------------------------------------------------------------------------
1 | import { apiUrl, api } from "../api";
2 |
3 | const state = {
4 | popupVisible: false,
5 | loggedIn: false,
6 | youtubeAccess: false,
7 | admin: false,
8 | avatar: undefined,
9 | username: undefined,
10 | karma: 0
11 | };
12 |
13 | const getters = {
14 | isLoggedIn: state => state.loggedIn,
15 | isAdmin: state => state.admin,
16 | isPopupVisible: state => state.popupVisible,
17 | hasYoutubeAccess: state => state.youtubeAccess
18 | };
19 |
20 | const actions = {
21 | bootstrap({ commit }) {
22 | api.get("/auth/loggedIn").then(({ data }) => commit("loggedIn", data));
23 | },
24 | showPopup({ commit }) {
25 | commit("popupVisible", true);
26 | const classes = document.documentElement.classList;
27 | classes?.add("is-clipped");
28 | },
29 | hidePopup({ commit }) {
30 | commit("popupVisible", false);
31 | const classes = document.documentElement.classList;
32 | classes?.remove("is-clipped");
33 | },
34 | // eslint-disable-next-line no-unused-vars
35 | login({ commit }, provider) {
36 | window.location.href =
37 | apiUrl + "auth/" + provider + "?returnTo=" + window.location.href;
38 | },
39 | logout() {
40 | window.location.href =
41 | apiUrl + "auth/logout" + "?returnTo=" + window.location.href;
42 | }
43 | };
44 |
45 | const mutations = {
46 | loggedIn: (
47 | state,
48 | { loggedIn, admin, avatar, username, karma, youtubeAccess }
49 | ) => {
50 | state.loggedIn = loggedIn;
51 | state.username = username;
52 | state.avatar = avatar;
53 | state.admin = admin;
54 | state.karma = karma;
55 | state.youtubeAccess = youtubeAccess;
56 | },
57 | popupVisible: (state, visible) => {
58 | state.popupVisible = visible;
59 | }
60 | };
61 |
62 | export default {
63 | namespaced: true,
64 | state,
65 | getters,
66 | actions,
67 | mutations
68 | };
69 |
--------------------------------------------------------------------------------
/vue/src/VideoComment.vue:
--------------------------------------------------------------------------------
1 |
2 | article.media
3 | figure.media-left
4 | p.image.is-32x32
5 | img.is-rounded(:src='comment.authorProfileImageUrl')
6 | .content
7 | p
8 | strong {{comment.authorDisplayName}}
9 | span.ml-1.mr-1 · {{comment.publishedAt | ago}}
10 | br
11 | span(style="white-space: pre-line") {{comment.textOriginal}}
12 | br
13 | .columns.is-variable.is-1.is-vcentered.is-mobile
14 | .column.py-2.px-1.is-narrow(v-if="comment.canReply && !replyOpen" )
15 | a.has-text-weight-bold.mr-3(@click="reply()") reply
16 | .column.py-2.px-1.is-narrow(v-if="comment.totalReplyCount && !replies.length")
17 | a.has-text-weight-bold(@click="showReplies(comment.id)") show {{comment.totalReplyCount}} replies
18 | VideoCommentEditor(v-if="replyOpen" :replyUrl="'/youtube/' + comment.id + '/replies'" @commented="replyAdded")
19 | VideoComment(v-for="reply in replies" :key="reply.id" :comment="reply" :video="video")
20 |
21 |
26 |
57 |
--------------------------------------------------------------------------------
/vue/src/EditVideo.vue:
--------------------------------------------------------------------------------
1 |
2 | .modal.is-active
3 | .modal-background
4 | .modal-content
5 | .columns.is-mobile
6 | .column.is-10.is-offset-1.modal-body.p6
7 | .columns
8 | .column
9 | .field
10 | .control
11 | textarea.is-small.textarea(rows="25" type="text", v-model="prettyVideo")
12 | br
13 | br
14 | .columns
15 | .column
16 | .field
17 | .control
18 | .buttons.is-pulled-right
19 | button.button.is-success(@click="saveVideo()" v-bind:class="{'is-loading': saving}" :disabled="!newVideo") Save
20 | button.button(@click="close()") Cancel
21 | .modal-close.is-large(aria-label="close" @click="close()")
22 |
23 |
26 |
34 |
80 |
--------------------------------------------------------------------------------
/express/src/routes/tweets.js:
--------------------------------------------------------------------------------
1 | const Twit = require("twit");
2 | const { adminsOnly } = require("../libs/Middlewares");
3 | const { Youtube } = require("../libs/Youtube");
4 | const asyncHandler = require('express-async-handler')
5 | const router = require("express").Router();
6 |
7 | const config = {
8 | consumer_key: process.env.AUTO_TWT_CONSUMER_KEY || "none",
9 | consumer_secret: process.env.AUTO_TWT_CONSUMER_SECRET || "none",
10 | access_token: process.env.AUTO_TWT_ACCESS_TOKEN || "none",
11 | access_token_secret: process.env.AUTO_TWT_ACCESS_SECRET || "none",
12 | };
13 | const twitter = new Twit(config);
14 |
15 | function random(min, max) {
16 | return Math.floor(Math.random() * (max - min + 1)) + min;
17 | }
18 |
19 | router.post("/", adminsOnly, asyncHandler(async (req, res) => {
20 | const video = req.body
21 | const watchingNow = random(5, 50);
22 | const comments = await new Youtube(process.env.YOUTUBE_API_KEY).fetchCommentCount(video.objectID);
23 | await tweetTrending(video, watchingNow, comments);
24 | res.sendStatus(200);
25 | }))
26 |
27 | function kilo(num) {
28 | return num < 1000 ? num : Math.floor(num / 1000) + "K"
29 | }
30 |
31 | async function tweetTrending(video, watchingNow, comments) {
32 | if (!video?.objectID) {
33 | throw new Error("No video to tweet");
34 | }
35 | if (!watchingNow) {
36 | throw new Error("Zero or no people watching.")
37 | }
38 | if (!comments) {
39 | throw new Error("Zero or no people commented.")
40 | }
41 |
42 |
43 | const status = [
44 | ...[
45 | `@${video.speakerTwitters[0]}:`,
46 | `📈 Your talk "${video.title}" is trending on DevTube:`,
47 | `❤️ ${kilo(video.likes)} likes`,
48 | `✍️ ${kilo(comments)} comments `,
49 | `📺 ${watchingNow} watching now`,
50 | "",
51 | `https://dev.tube/video/${video.objectID}`,
52 | ],
53 | ]
54 | .filter((line) => line !== undefined)
55 | .join("\n");
56 |
57 | return twitter
58 | .post("statuses/update", { status })
59 | .catch((e) =>
60 | console.error(new Error(`Tweeting of ${video.objectID} failed: ${e}`))
61 | );
62 | };
63 |
64 | module.exports = router;
65 | module.exports.tweetTrending = tweetTrending
--------------------------------------------------------------------------------
/vue/src/VideoCard.vue:
--------------------------------------------------------------------------------
1 |
2 | .column.video.mb-6
3 | .columns.is-mobile.is-multiline(v-bind:class="{'is-watched': listedIn('watched'), 'has-background-danger-light': video.status === 'submitted'}")
4 | .column.is-3-desktop.is-3-tablet.is-12-mobile.is-hidden-mobile
5 | span.has-text-grey.is-size-7 {{addedAgo(video)}}
6 | br
7 | span.has-text-weight-bold.has-text-grey.is-size-7 thanks to @{{video.contributor}}
8 | .column.is-narrow
9 | router-link(:to="'/@' + video.speakerTwitters[speakerIndex]" :title="video.speakerNames[speakerIndex]")
10 | figure.image.is-48x48
11 | img.avatar.is-rounded(:src="avatar(video, speakerIndex)" :alt="video.speakerNames[speakerIndex]" width="48px" height="48px")
12 | .column
13 | h1.is-4.title
14 | router-link.has-text-grey-darker(:to="{ name: 'video', params: { id: video.objectID } }")
15 | | {{ video.title }} ({{video.recordingDate | year}})
16 | | —
17 | | {{video.speakerNames[speakerIndex]}}
18 | .tags
19 | router-link.has-text-weight-bold.has-text-grey.tag(v-for="topic in video.topics" :key="topic" :to="'/~' + encodeURIComponent(topic)") {{topic}}
20 | WatchingNow(:video="video")
21 | .columns
22 | .column
23 | VideoActions(:video="video")
24 |
25 |
46 |
66 |
--------------------------------------------------------------------------------
/express/src/routes/videos.js:
--------------------------------------------------------------------------------
1 | const asyncHandler = require('express-async-handler')
2 | const router = require("express").Router();
3 | const { authenticated, adminsOnly } = require("../libs/Middlewares");
4 | const { updateVideo, createVideo, oneVideo, replaceVideo, deleteVideo } = require("../libs/Datastore")
5 | const memoize = require("memoizee");
6 | const Joi = require("joi")
7 |
8 | const videoSchema = memoize(() => {
9 | const schema = Joi.object().keys({
10 | objectID: Joi.string().required(),
11 | title: Joi.string().required(),
12 | channelId: Joi.string().required(),
13 | channelTitle: Joi.string().required(),
14 | contributor: Joi.string().required(),
15 | likes: Joi.number().required(),
16 | duration: Joi.string().isoDuration().required(),
17 | recordingDate: Joi.date().iso().required(),
18 | submissionDate: Joi.date().iso().required(),
19 | status: Joi.string().valid("approved", "submitted").required(),
20 | speakerNames: Joi.array().min(1).required(),
21 | speakerTwitters: Joi.array().min(1).required(),
22 | topics: Joi.array().min(1).required(),
23 | series: Joi.array()
24 | })
25 | return schema;
26 | })
27 |
28 | router.get("/:videoId", asyncHandler(async (req, res) => {
29 | const { videoId } = req.params;
30 | const video = await oneVideo(videoId);
31 | if (!video) {
32 | res.sendStatus(404)
33 | }
34 | res.json(video);
35 | }));
36 |
37 | router.delete("/:videoId", adminsOnly, asyncHandler(async (req, res) => {
38 | const videoId = req.params.videoId;
39 | await deleteVideo(videoId);
40 | res.sendStatus(200);
41 | }))
42 |
43 | router.post("/:videoId/like", authenticated, asyncHandler(async (req, res) => {
44 | const videoId = req.params.videoId;
45 | await updateVideo(videoId, (video) => {
46 | video.likes++
47 | res.send("" + video.likes)
48 | })
49 |
50 | }))
51 |
52 | router.post("/", authenticated, asyncHandler(async (req, res) => {
53 | const status = req.user.admin ? "approved" : "submitted"
54 | const video = Joi.attempt({ ...req.body, status }, videoSchema());
55 | await createVideo(video);
56 | res.sendStatus(200)
57 | }))
58 |
59 | router.put("/", adminsOnly, asyncHandler(async (req, res) => {
60 | const video = Joi.attempt(req.body, videoSchema());
61 | await replaceVideo(video);
62 | res.sendStatus(200);
63 | }))
64 |
65 | module.exports = router;
66 |
--------------------------------------------------------------------------------
/express/src/routes/youtube.js:
--------------------------------------------------------------------------------
1 | const asyncHandler = require('express-async-handler')
2 | const router = require("express").Router();
3 | const { authenticated } = require("../libs/Middlewares")
4 | const { oneVideo } = require('../libs/Datastore');
5 | const { Youtube } = require("../libs/Youtube")
6 |
7 | function userAuth(req) {
8 | const { google } = require('googleapis');
9 | const oauthClient = new google.auth.OAuth2(process.env.GOOG_CLIENT_ID, process.env.GOOG_CLIENT_SECRET, "");
10 | oauthClient.setCredentials(req.user.google)
11 | return oauthClient;
12 | }
13 |
14 | router.get("/:commentId/replies", asyncHandler(async (req, res) => {
15 | const { commentId } = req.params;
16 | const replies = await new Youtube(process.env.YOUTUBE_API_KEY).fetchReplies(commentId);
17 | res.json(replies);
18 | }));
19 |
20 | router.post("/:commentId/replies", authenticated, asyncHandler(async (req, res) => {
21 | const { commentId } = req.params;
22 | const { text } = req.body;
23 | const newReply = await new Youtube(userAuth(req)).replyToComment(commentId, text);
24 | res.json(newReply);
25 | }));
26 |
27 | router.get("/:videoId/comments", asyncHandler(async (req, res) => {
28 | const { videoId } = req.params;
29 | const pageToken = req.query.nextPageToken;
30 | try {
31 | const comments = await new Youtube(process.env.YOUTUBE_API_KEY).fetchComments(videoId, pageToken);
32 | res.json(comments);
33 | } catch (e) {
34 | const [error] = e.errors
35 | if (error.reason === 'commentsDisabled') {
36 | res.json({ comments: [], commentsDisabled: true });
37 | } else {
38 | throw e;
39 | }
40 | }
41 |
42 | }));
43 |
44 | router.post("/:videoId/comments", authenticated, asyncHandler(async (req, res) => {
45 | const { videoId } = req.params;
46 | const { text } = req.body;
47 | const newComment = await new Youtube(userAuth(req)).addComment(videoId, text);
48 | res.json(newComment)
49 | }));
50 |
51 |
52 | router.get("/:videoId", authenticated, asyncHandler(async (req, res) => {
53 | const { videoId } = req.params;
54 |
55 | if (await oneVideo(videoId)) {
56 | res.send({ error: "Video already added." });
57 | return;
58 | }
59 |
60 | const { video, error } = await new Youtube(process.env.YOUTUBE_API_KEY).fetchVideo(videoId);
61 | if (error) {
62 | res.send({ error });
63 | return
64 | }
65 |
66 | res.send({ ...video, contributor: req.user.username });
67 |
68 | }));
69 |
70 | module.exports = router;
71 |
--------------------------------------------------------------------------------
/vue/src/VideoCommentEditor.vue:
--------------------------------------------------------------------------------
1 |
2 | article.media
3 | figure.media-left
4 | p.image.is-rounded.is-32x32
5 | img.is-rounded(:src='$store.state.auth.avatar')
6 | .form(style="width: 100%")
7 | .field
8 | p.control
9 | textarea.textarea.is-small(:disabled="!hasYoutubeAccess()" placeholder='Type something here...' v-model="text" )
10 | .field
11 | .columns.is-vcentered
12 | .column.is-narrow(v-if="hasYoutubeAccess()")
13 | a.button.is-small(@click="postComment()" :disabled="!text.length" v-bind:class="{ 'is-loading': isWorking }") Post
14 | .column(v-else)
15 | .columns.is-vcentered.is-mobile
16 | .column.is-narrow
17 | a(@click="youtubeLogin()")
18 | #goog(alt="Signin with Google")
19 | .column
20 | span to post comments
21 | .column
22 | span.help(v-if="error")
23 | | Oops. something went wrong.
24 | a(@click="youtubeLogin()") Sign in to YouTube
25 | | and try again.
26 |
27 |
41 |
79 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://github.com/watch-devtube/web/)
2 |
3 | # DevTube
4 |
5 | This repository contains `Vue.js` frontend and `Express.js` backend for DevTube.
6 |
7 | # Why DevTube?
8 |
9 | DevTube contains the best `350+` tech talks from different sources – @eduardsi's personal favorites, github lists, YouTube most liked. You can say that DevTube is YouTube uncluttered + some secret gems reuploaded from InfoQ, Vimeo, and private video archives. The talks are updated and contributed regularly thanks to the community.
10 |
11 | **💎 Discover hidden gems** – Watch videos that are not publicly available on YouTube (e.g. [Chad Fowler's "Tiny"](https://dev.tube/video/NXSS01n97G0)).
12 |
13 | **🕛 Save time** – Watch only the best talks, curated by the community, grouped by categories and speakers. Quality beats quantity.
14 |
15 | **🔖 Create lists** – Watch later, bookmark, and keep track of watched videos.
16 |
17 | **💬 Discuss** – Read, write, and reply to comments directly from DevTube.
18 |
19 | **❤️ Contribute** – Get karma for video contributions. Your name will also be visible next to the video.
20 |
21 | **🔔 Subscribe** – Stay up-to-date with the latest videos via RSS. Too busy? Receive one tech talk per week.
22 |
23 | **🧘 Fewer distractions** – No annoying YouTube algorithms, irrelevant videos, ads, and tracking.
24 |
25 | # How to run DevTube locally
26 |
27 | ### Get datastore access
28 |
29 | Ask the repo owners for Google Datastore credentials, then put them in `./datastore_key.json`.
30 |
31 | ### Configure env variables
32 |
33 | Create a file `./express/.env` with the following variables:
34 |
35 | ```
36 | COOKIE_SECRET = DEVDEVDEVDEVDEVDEVDEVDEVDEVDEVDEV
37 | DEVTUBE_HOST = http://devtube.xxx:8080
38 |
39 | YOUTUBE_API_KEY =
40 |
41 | TWITTER_CONSUMER_KEY =
42 | TWITTER_CONSUMER_SECRET =
43 |
44 | GH_CLIENT_ID =
45 | GH_CLIENT_SECRET =
46 |
47 | GOOG_CLIENT_ID =
48 | GOOG_CLIENT_SECRET =
49 | ```
50 |
51 | ### Add new entries to /etc/hosts file
52 |
53 | ```
54 | 127.0.0.1 devtube.xxx
55 | ```
56 |
57 | #### Run backend
58 |
59 | ```bash
60 | # From ./express directory run:
61 | npm install
62 | npm run dev
63 | ```
64 |
65 | #### Run frontend
66 |
67 | ```bash
68 | # from ./vue directory run:
69 | npm install
70 | npm run dev
71 | ```
72 |
73 | > 🚀 DevTube front-end is now running on [devtube.xxx:8080](http://devtube.xxx:8080)
74 |
--------------------------------------------------------------------------------
/vue/src/NavBar.vue:
--------------------------------------------------------------------------------
1 |
2 | section.section.header.p-5.mb-6
3 | .container(role="navigation", aria-label="main navigation")
4 | .columns.is-mobile.is-vcentered
5 | .column.is-narrow
6 | router-link(:to="'/'")
7 | img.logo(src="/logo_dark.png", srcset="/logo_dark.svg", alt="logo")
8 | .column
9 | .is-pulled-right
10 | .columns.is-mobile.is-vcentered.is-size-5.is-size-7-mobile
11 | .column.is-narrow(v-if="isLoggedIn && lists.later.length")
12 | router-link.has-text-weight-bold.has-text-white.is-size-7(:to="'/later'") later
13 | span.count.has-text-white.is-size-7 {{lists.later.length}}
14 | .column.is-narrow(v-if="isLoggedIn && lists.favorites.length")
15 | router-link.has-text-weight-bold.has-text-white.is-size-7(:to="'/favorites'") favorites
16 | span.count.has-text-white.is-size-7 {{lists.favorites.length}}
17 | .column.is-narrow(v-if="isLoggedIn &&lists.watched.length")
18 | router-link.has-text-weight-bold.has-text-white.is-size-7(:to="'/watched'") watched
19 | span.count.has-text-white.is-size-7 {{lists.watched.length}}
20 | .column.is-narrow
21 | .columns.is-2.is-variable.is-vcentered.is-mobile(v-if="isLoggedIn")
22 | .column.is-hidden-mobile.is-size-7.is-narrow
23 | font-awesome-icon(:icon="['far', 'heart']").has-text-danger
24 | span.has-text-white {{karma()}}
25 | .column.is-hidden-mobile(v-if="avatar")
26 | figure.image.is-32x32
27 | img.is-rounded(:src="avatar")
28 | .column
29 | button.button.is-text.has-text-white.is-small(@click="logout()") logout
30 | button.button.is-text.is-small.has-text-white(v-else @click="showPopup()") login
31 |
32 |
53 |
77 |
--------------------------------------------------------------------------------
/vue/src/Banner.vue:
--------------------------------------------------------------------------------
1 |
2 |
19 |
20 |
123 |
--------------------------------------------------------------------------------
/.github/workflows/server-deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | schedule:
8 | # Run every 4 hours
9 | - cron: "0 */4 * * *"
10 |
11 | jobs:
12 | worker:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@v4
16 | - name: Publish
17 | uses: cloudflare/wrangler-action@v3
18 | with:
19 | apiToken: ${{ secrets.CF_API_TOKEN }}
20 | workingDirectory: "worker"
21 |
22 | web:
23 | runs-on: ubuntu-latest
24 | defaults:
25 | run:
26 | working-directory: ./vue
27 | steps:
28 | - name: checkout
29 | uses: actions/checkout@v3
30 | - name: install node
31 | uses: actions/setup-node@v3
32 | with:
33 | node-version: "16"
34 | - name: build
35 | run: npm install && npm run build
36 | - name: deploy
37 | uses: w9jds/firebase-action@master
38 | with:
39 | args: deploy --only hosting
40 | env:
41 | FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
42 | PROJECT_PATH: ./vue
43 | server:
44 | permissions:
45 | contents: "read"
46 | id-token: "write"
47 | runs-on: ubuntu-latest
48 | defaults:
49 | run:
50 | working-directory: ./express
51 | steps:
52 | - name: checkout
53 | uses: actions/checkout@v3
54 |
55 | - name: build
56 | uses: actions/setup-node@v3
57 | with:
58 | node-version: "14"
59 |
60 | - name: install node deps
61 | run: npm install --only=production
62 |
63 | - name: env file
64 | run: |
65 | touch .env
66 | echo COOKIE_SECRET=${{ secrets.COOKIE_SECRET }} >> .env
67 | echo TWITTER_CONSUMER_KEY=${{ secrets.TWITTER_CONSUMER_KEY }} >> .env
68 | echo TWITTER_CONSUMER_SECRET=${{ secrets.TWITTER_CONSUMER_SECRET }} >> .env
69 |
70 | echo AUTO_TWT_CONSUMER_KEY=${{ secrets.AUTO_TWT_CONSUMER_KEY }} >> .env
71 | echo AUTO_TWT_CONSUMER_SECRET=${{ secrets.AUTO_TWT_CONSUMER_SECRET }} >> .env
72 | echo AUTO_TWT_ACCESS_TOKEN=${{ secrets.AUTO_TWT_ACCESS_TOKEN }} >> .env
73 | echo AUTO_TWT_ACCESS_SECRET=${{ secrets.AUTO_TWT_ACCESS_SECRET }} >> .env
74 |
75 | echo GH_CLIENT_ID=${{ secrets.GH_CLIENT_ID }} >> .env
76 | echo GH_CLIENT_SECRET=${{ secrets.GH_CLIENT_SECRET }} >> .env
77 | echo GOOG_CLIENT_ID=${{ secrets.GOOG_CLIENT_ID }} >> .env
78 | echo GOOG_CLIENT_SECRET=${{ secrets.GOOG_CLIENT_SECRET }} >> .env
79 | echo YOUTUBE_API_KEY=${{ secrets.YOUTUBE_API_KEY }} >> .env
80 | cat .env
81 |
82 | - name: gcp auth
83 | uses: "google-github-actions/auth@v2"
84 | with:
85 | credentials_json: "${{ secrets.GCP_CREDENTIALS }}"
86 |
87 | - name: gcloud cli install
88 | uses: "google-github-actions/setup-gcloud@v2"
89 |
90 | - name: prepare data dir
91 | run: mkdir ./data
92 |
93 | - name: build index
94 | run: npm run reindex
95 |
96 | - name: deploy
97 | run: "gcloud functions deploy api --trigger-http --runtime nodejs18 --memory=1024MB --timeout=1m --project=dev-tube --no-gen2"
98 |
--------------------------------------------------------------------------------
/express/src/routes/user.js:
--------------------------------------------------------------------------------
1 | const router = require("express").Router();
2 |
3 | const { authenticated } = require("../libs/Middlewares");
4 | const { datastoreForever } = require("../libs/Datastore")
5 |
6 | router.get("/bootstrap", async (req, res) => {
7 |
8 | if (!req.user) {
9 | res.json({})
10 | return;
11 | }
12 |
13 | const userKey = req.user.email
14 | try {
15 | const [user] = await datastoreForever().get(datastoreForever().key(['user', userKey]));
16 | res.json(user)
17 | } catch (err) {
18 | console.log(err);
19 | res.sendStatus(500)
20 | }
21 | })
22 |
23 | router.post("/weekly-subscription", authenticated, async (req, res) => {
24 | const userKey = req.user.email
25 | try {
26 | const key = datastoreForever().key(['user', userKey]);
27 | const tx = datastoreForever().transaction();
28 |
29 | const [user] = await tx.get(key);
30 | const data = user || {
31 | later: [],
32 | favorites: [],
33 | watched: [],
34 | }
35 |
36 | data.subscribedToWeekly = true;
37 |
38 | tx.upsert({ key, data });
39 | await tx.commit();
40 |
41 | res.json(data)
42 | } catch (e) {
43 | console.error(e);
44 | res.sendStatus(500);
45 | }
46 | })
47 |
48 | router.delete("/weekly-subscription", authenticated, async (req, res) => {
49 | const userKey = req.user.email
50 | try {
51 | const key = datastoreForever().key(['user', userKey]);
52 | const tx = datastoreForever().transaction();
53 | const [user] = await tx.get(key);
54 | user.subscribedToWeekly = false
55 | tx.upsert({ key, data: user });
56 | await tx.commit();
57 | res.json(user)
58 | } catch (e) {
59 | console.error(e);
60 | res.sendStatus(500);
61 | }
62 | })
63 |
64 | router.delete("/lists/:list/:videoId", authenticated, async (req, res) => {
65 | const userKey = req.user.email
66 | const { list, videoId } = req.params
67 | if (!list || !videoId || !userKey) {
68 | res.sendStatus(500)
69 | return;
70 | }
71 |
72 | const key = datastoreForever().key(['user', userKey])
73 |
74 | try {
75 | const tx = datastoreForever().transaction();
76 | const [data] = await tx.get(key);
77 |
78 |
79 | const index = data[list].indexOf(videoId);
80 | if (index != -1) {
81 | data[list].splice(index, 1);
82 | tx.upsert({ key, data });
83 | await tx.commit();
84 | }
85 |
86 | res.json(data[list])
87 | } catch {
88 | res.sendStatus(500);
89 | }
90 |
91 | })
92 |
93 | router.post("/lists/:list/:videoId", authenticated, async (req, res) => {
94 | const { list, videoId } = req.params
95 | if (!list || !videoId) {
96 | res.sendStatus(500)
97 | return;
98 | }
99 |
100 | const userKey = req.user.email
101 |
102 | try {
103 | const key = datastoreForever().key(['user', userKey]);
104 | const tx = datastoreForever().transaction();
105 |
106 | const [user] = await tx.get(key);
107 | const data = user || {
108 | later: [],
109 | favorites: [],
110 | watched: []
111 | }
112 |
113 | if (!data[list].includes(videoId)) {
114 | data[list].push(videoId);
115 | tx.upsert({ key, data });
116 | await tx.commit();
117 | }
118 |
119 | res.json(data[list])
120 | } catch (e) {
121 | console.error(e);
122 | res.sendStatus(500);
123 | }
124 | });
125 |
126 | module.exports = router;
127 |
--------------------------------------------------------------------------------
/express/src/libs/Datastore.js:
--------------------------------------------------------------------------------
1 | const jsonDiff = require("json-diff");
2 | const memoize = require("memoizee");
3 | const { readFileSync } = require("fs");
4 |
5 | const datastoreForever = memoize(() => {
6 | const { Datastore } = require("@google-cloud/datastore");
7 | const datastore = new Datastore();
8 | return datastore;
9 | })
10 |
11 | const processVideos = (mapper = (data) => data, done = () => { }) => {
12 | let changed = 0;
13 | let processed = 0;
14 | datastoreForever()
15 | .runQueryStream(datastoreForever().createQuery("videos").filter("status", "approved"))
16 | .on("error", console.error)
17 | .on("data", (data) => {
18 | const key = data[datastoreForever().KEY];
19 | const copy = { ...data };
20 | mapper(copy);
21 | const diff = jsonDiff.diffString(data, copy);
22 | if (diff) {
23 | datastoreForever().update({ key, data: copy });
24 | changed++;
25 | console.log(diff);
26 | }
27 | processed++
28 | })
29 | .on("end", () => {
30 | console.log(`Processing has completed. ${processed} entities processed. ${changed} updated.`)
31 | done()
32 | }
33 | );
34 | };
35 |
36 | const oneVideo = async (videoID) => {
37 | const key = datastoreForever().key(["videos", videoID])
38 | const [video] = await datastoreForever().get(key);
39 | return video;
40 | }
41 |
42 | const deleteVideo = async (videoID) => {
43 | const key = datastoreForever().key(["videos", videoID])
44 | return datastoreForever().delete(key);
45 | }
46 |
47 | const updateVideo = async (videoID, mutate) => {
48 | const key = datastoreForever().key(["videos", videoID])
49 | const [video] = await datastoreForever().get(key);
50 |
51 | const newVideo = { ...video }
52 | mutate(newVideo);
53 | const diff = jsonDiff.diffString(video, newVideo);
54 | console.log(diff)
55 | return datastoreForever().update({ key, data: newVideo })
56 | }
57 |
58 |
59 |
60 | const replaceVideo = async (newVideo) => {
61 | const key = datastoreForever().key(["videos", newVideo.objectID])
62 | const [video] = await datastoreForever().get(key);
63 | const diff = jsonDiff.diffString(video, newVideo);
64 | console.log(diff)
65 | return datastoreForever().update({ key, data: newVideo })
66 | }
67 |
68 | const createVideo = async (newVideo) => {
69 | const key = datastoreForever().key(["videos", newVideo.objectID])
70 | return datastoreForever().insert({ key, data: newVideo })
71 | }
72 |
73 | const searchApprovedVideos = () => {
74 | const q = datastoreForever().createQuery("videos").filter("status", "approved");
75 | return datastoreForever().runQuery(q);
76 | }
77 |
78 | const searchAllVideos = () => {
79 | const q = datastoreForever().createQuery("videos");
80 | return datastoreForever().runQuery(q).then(([videos]) => videos);
81 | }
82 |
83 | const readApprovedVideos = () => JSON.parse(readFileSync('./data/videos.json', { encoding: "utf8" }));
84 | const searchApprovedVideosForever = memoize(readApprovedVideos)
85 |
86 | module.exports.processVideos = processVideos;
87 | module.exports.oneVideo = oneVideo;
88 | module.exports.updateVideo = updateVideo;
89 | module.exports.createVideo = createVideo;
90 | module.exports.replaceVideo = replaceVideo;
91 | module.exports.deleteVideo = deleteVideo;
92 | module.exports.searchApprovedVideos = searchApprovedVideos;
93 | module.exports.searchAllVideos = searchAllVideos;
94 | module.exports.searchApprovedVideosForever = searchApprovedVideosForever;
95 | module.exports.datastoreForever = datastoreForever;
--------------------------------------------------------------------------------
/vue/src/main.js:
--------------------------------------------------------------------------------
1 | import Vue from "vue";
2 | import Vuex from "vuex";
3 |
4 | import VueHead from "vue-head";
5 | import VueRouter from "vue-router";
6 | import Notifications from "vue-notification";
7 | import VueObserveVisibility from "vue-observe-visibility";
8 |
9 | import { library } from "@fortawesome/fontawesome-svg-core";
10 | import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
11 |
12 | import {
13 | faTwitter,
14 | faGithub,
15 | faGoogle,
16 | faLinkedin
17 | } from "@fortawesome/free-brands-svg-icons";
18 | import {
19 | faTimes,
20 | faCheck,
21 | faEnvelope,
22 | faPlus,
23 | faCircle,
24 | faHashtag,
25 | faSquareRss
26 | } from "@fortawesome/free-solid-svg-icons";
27 | import { faStar as fasStar } from "@fortawesome/free-solid-svg-icons/faStar";
28 | import { faCheckCircle as fasCheckCircle } from "@fortawesome/free-solid-svg-icons/faCheckCircle";
29 | import { faClock as fasClock } from "@fortawesome/free-solid-svg-icons/faClock";
30 | import { faHeart as fasHeart } from "@fortawesome/free-solid-svg-icons/faHeart";
31 | import {
32 | faHeart,
33 | faEye,
34 | faEyeSlash,
35 | faStar,
36 | faCheckCircle,
37 | faClock,
38 | faEdit
39 | } from "@fortawesome/free-regular-svg-icons";
40 | library.add(
41 | faTimes,
42 | faCheck,
43 | faClock,
44 | faStar,
45 | faEnvelope,
46 | faPlus,
47 | faGithub,
48 | faCircle,
49 | faGoogle,
50 | faEdit,
51 | faEyeSlash,
52 | faHashtag,
53 | faHeart,
54 | faSquareRss,
55 | faTwitter,
56 | faLinkedin,
57 | faEye,
58 | faCheckCircle,
59 | fasHeart,
60 | fasCheckCircle,
61 | fasStar,
62 | fasClock,
63 | faClock
64 | );
65 |
66 | import VideoComment from "./VideoComment.vue";
67 | Vue.component("VideoComment", VideoComment);
68 | Vue.component("font-awesome-icon", FontAwesomeIcon);
69 |
70 | import App from "./App.vue";
71 | import Watch from "./Watch.vue";
72 | import Search from "./Search.vue";
73 | import { ago, published, year, durationFull } from "./helpers/filters";
74 |
75 | import auth from "./auth";
76 | import edit from "./edit";
77 | import user from "./user";
78 |
79 | Vue.use(VueHead, {
80 | separator: "",
81 | complement: ""
82 | });
83 | Vue.use(Vuex);
84 | Vue.use(Notifications);
85 | Vue.use(VueRouter);
86 | Vue.use(VueObserveVisibility);
87 |
88 | Vue.filter("durationFull", durationFull);
89 | Vue.filter("published", published);
90 | Vue.filter("year", year);
91 | Vue.filter("ago", ago);
92 | require("./styles/main.scss");
93 |
94 | const router = new VueRouter({
95 | mode: "history",
96 | linkExactActiveClass: "is-active",
97 | routes: [
98 | { name: "video", path: "/video/:id", component: Watch, props: true },
99 | {
100 | name: "search",
101 | path: "/:query?",
102 | component: Search
103 | }
104 | ],
105 | scrollBehavior() {
106 | return { x: 0, y: 0 };
107 | }
108 | });
109 |
110 | const store = new Vuex.Store({
111 | modules: {
112 | auth,
113 | edit,
114 | user
115 | },
116 | strict: true
117 | });
118 |
119 | import Plausible from 'plausible-tracker'
120 | const { enableAutoPageviews } = Plausible({
121 | domain: 'dev.tube',
122 | apiHost: 'https://a.devternity.com'
123 | })
124 | enableAutoPageviews()
125 |
126 | new Vue({
127 | el: "#app",
128 | store,
129 | router,
130 | render: h => h(App),
131 | async created() {
132 | store.dispatch("auth/bootstrap");
133 | store.dispatch("user/bootstrap");
134 | }
135 | });
136 |
--------------------------------------------------------------------------------
/express/src/routes/search.js:
--------------------------------------------------------------------------------
1 | const asyncHandler = require('express-async-handler')
2 | const { datastoreForever, searchApprovedVideosForever, searchAllVideos } = require("../libs/Datastore");
3 | const { authenticated } = require('../libs/Middlewares');
4 | const router = require("express").Router();
5 | const _ = require("lodash");
6 |
7 | const NOTHING_FOUND = `¯\\_(ツ)_/¯ No talks matching your criteria`
8 |
9 | router.post("/", asyncHandler(async (req, res) => {
10 | const { p, s } = req.body;
11 |
12 | const search = req.user?.admin ? searchAllVideos : searchApprovedVideosForever
13 | const foundItems = await search();
14 | const { videos, more } = await paginate(foundItems, p, s);
15 |
16 | res.json({
17 | videos,
18 | more
19 | })
20 | }));
21 |
22 | router.post("/@:speaker", asyncHandler(async (req, res) => {
23 | const { speaker } = req.params;
24 | const { p, s } = req.body;
25 |
26 | const foundItems = await searchApprovedVideosForever()
27 | const someItems = foundItems.filter(it => it.speakerTwitters.includes(speaker))
28 |
29 | const { videos, more } = await paginate(someItems, p, s);
30 |
31 | const speakerIndex = videos[0]?.speakerTwitters.indexOf(speaker)
32 | const whois = videos[0]?.speakerNames[speakerIndex];
33 | res.json({
34 | videos,
35 | more,
36 | speakerIndex,
37 | title: videos.length ? `The best tech talks by ${whois}` : NOTHING_FOUND
38 | })
39 | }));
40 |
41 | router.post("/~:topic(*)", asyncHandler(async (req, res) => {
42 | const { topic } = req.params;
43 | const { p, s } = req.body;
44 |
45 | const foundItems = await searchApprovedVideosForever()
46 | const someItems = foundItems.filter(it => it.topics.includes(topic))
47 |
48 | const { videos, more } = await paginate(someItems, p, s);
49 | res.json({
50 | videos,
51 | more,
52 | title: videos.length ? `The best ${topic} talks` : NOTHING_FOUND
53 | })
54 | }));
55 |
56 | router.post("/:list(later|watched|favorites)", authenticated, asyncHandler(async (req, res) => {
57 | const list = req.params.list;
58 | const tx = datastoreForever().transaction();
59 | const [lists] = await tx.get(datastoreForever().key(['user', req.user.email]));
60 | if (!lists || !lists[list] || !lists[list].length) {
61 | res.json({})
62 | return
63 | }
64 | const videoIds = lists[list];
65 |
66 | const keys = videoIds.map(id => datastoreForever().key(["videos", id]));
67 | const [matches] = await tx.get(keys);
68 | matches.sort(match => -videoIds.indexOf(match.objectID))
69 | const videos = matches;
70 |
71 | const titles = {
72 | later: `Tech talks to watch later (${matches.length})`,
73 | watched: `Tech talks I watched (${matches.length})`,
74 | favorites: `My favorite tech talks (${matches.length})`
75 | }
76 |
77 | res.json({
78 | videos,
79 | title: titles[list]
80 | })
81 | }));
82 |
83 | async function paginate(items, p, s) {
84 | const pg = Number(p) || 1;
85 | const pgSize = 30;
86 | const pgTotal = Math.ceil(items.length / pgSize);
87 | const offset = (pg - 1) * pgSize;
88 | const more = pgTotal === pg ? "" : pg + 1
89 |
90 | let sortedItems = items
91 | if (s === 'likes') {
92 | sortedItems = _.orderBy(items, ['likes'], ['desc'])
93 | }
94 | if (s === 'recent') {
95 | sortedItems = _.orderBy(items, ['submissionDate'], ['desc'])
96 | }
97 |
98 | const videos = _.drop(sortedItems, offset).slice(0, pgSize);
99 | return {
100 | videos,
101 | more
102 | }
103 | }
104 |
105 | module.exports = router;
106 |
--------------------------------------------------------------------------------
/vue/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
23 |
25 |
35 |
36 |
38 |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/vue/public/logo_dark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
23 |
25 |
35 |
36 |
38 |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/express/src/routes/auth.js:
--------------------------------------------------------------------------------
1 | const asyncHandler = require('express-async-handler')
2 | const router = require("express").Router();
3 | const passport = require("passport");
4 | const TwitterStrategy = require('passport-twitter');
5 | const GitHubStrategy = require('passport-github2');
6 | const GoogleStrategy = require('passport-google-oauth2');
7 | const { statsForever } = require('../libs/Stats');
8 |
9 | const { returnBack } = require("../libs/Middlewares")
10 |
11 | const successRedirect = process.env.DEVTUBE_HOST || 'https://dev.tube'
12 | const successReturnToOrRedirect = successRedirect
13 | const admins = ['eduards@sizovs.net', 'eduards@devternity.com', 'eduards.sizovs@gmail.com']
14 |
15 | function toUserProfile(request, access_token, refresh_token, profile, done) {
16 | const avatar = profile.photos[0].value
17 | const email = profile.emails[0].value
18 | if (!avatar || !email) {
19 | throw "Sorry, unable to retrieve user data from OAuth"
20 | }
21 |
22 | const admin = admins.includes(email);
23 |
24 | const user = request.user || {
25 | avatar,
26 | email,
27 | admin,
28 | username: profile.username || email.split("@")[0],
29 | provider: profile.provider
30 | };
31 |
32 | user[profile.provider] = { access_token, refresh_token }
33 |
34 | return done(null, user);
35 | }
36 |
37 | passport.use(new GitHubStrategy({
38 | clientID: process.env.GH_CLIENT_ID,
39 | clientSecret: process.env.GH_CLIENT_SECRET,
40 | callbackURL: "/api/auth/github/callback",
41 | passReqToCallback: true,
42 | scope: ['user:email'],
43 | proxy: true
44 | },
45 | toUserProfile
46 | ));
47 |
48 | passport.use(new TwitterStrategy({
49 | consumerKey: process.env.TWITTER_CONSUMER_KEY,
50 | consumerSecret: process.env.TWITTER_CONSUMER_SECRET,
51 | callbackURL: '/api/auth/twitter/callback',
52 | passReqToCallback: true,
53 | proxy: true,
54 | includeEmail: true
55 | },
56 | toUserProfile));
57 |
58 | passport.use(new GoogleStrategy({
59 | clientID: process.env.GOOG_CLIENT_ID,
60 | clientSecret: process.env.GOOG_CLIENT_SECRET,
61 | callbackURL: "/api/auth/google/callback",
62 | passReqToCallback: true,
63 | proxy: true
64 | },
65 | toUserProfile));
66 |
67 |
68 | passport.serializeUser((user, cb) => {
69 | cb(null, user);
70 | });
71 |
72 | passport.deserializeUser((obj, cb) => {
73 | cb(null, obj);
74 | });
75 |
76 | router.get('/loggedIn', asyncHandler(async (req, res) => {
77 | const loggedIn = !!req.user;
78 |
79 | const username = req.user?.username;
80 | const avatar = req.user?.avatar;
81 | const admin = !!req.user?.admin;
82 | const youtubeAccess = !!req.user?.google
83 |
84 | const calculateKarma = async () => {
85 | if (loggedIn) {
86 | const stats = await statsForever();
87 | return stats.karma[username] || 0
88 | } else {
89 | return 0;
90 | }
91 | }
92 |
93 | const karma = await calculateKarma();
94 |
95 | res.json({ loggedIn, admin, avatar, username, karma, youtubeAccess })
96 | }))
97 |
98 | router.get('/logout', (req, res) => {
99 | req.logout();
100 | req.session = null;
101 | res.redirect(req.query.returnTo);
102 | });
103 |
104 | const googleScopes = ['email', 'profile', 'https://www.googleapis.com/auth/youtube.force-ssl']
105 |
106 | router.get('/github', returnBack, passport.authenticate('github'));
107 | router.get('/twitter', returnBack, passport.authenticate('twitter'));
108 | router.get('/google', returnBack, passport.authenticate('google', {
109 | scope: googleScopes, accessType: 'offline', prompt: 'consent'
110 | }));
111 |
112 | router.get('/google/callback',
113 | passport.authenticate('google', { successReturnToOrRedirect, failureRedirect: '/' }));
114 |
115 | router.get('/twitter/callback',
116 | passport.authenticate('twitter', { successReturnToOrRedirect, failureRedirect: '/' }));
117 |
118 | router.get('/github/callback',
119 | passport.authenticate('github', { successReturnToOrRedirect, failureRedirect: '/' }));
120 |
121 | module.exports = router;
122 |
--------------------------------------------------------------------------------
/express/src/libs/Youtube.js:
--------------------------------------------------------------------------------
1 | const dayjs = require("dayjs");
2 | const memoize = require("memoizee");
3 |
4 | const googleForever = memoize(() => {
5 | const { google } = require('googleapis')
6 | return google;
7 | })
8 |
9 |
10 | function mapComment(it) {
11 | const { totalReplyCount, canReply } = it.snippet;
12 | const { authorDisplayName, authorProfileImageUrl, textOriginal, publishedAt, likeCount } = it.snippet.topLevelComment.snippet
13 | const comment = {
14 | id: it.id,
15 | authorDisplayName,
16 | authorProfileImageUrl,
17 | textOriginal,
18 | totalReplyCount,
19 | likeCount,
20 | publishedAt,
21 | canReply
22 | }
23 | return comment;
24 | }
25 |
26 | function mapCommentReply(c) {
27 | const { authorDisplayName, authorProfileImageUrl, textOriginal, publishedAt, likeCount } = c.snippet;
28 | const reply = {
29 | id: c.id,
30 | authorDisplayName,
31 | authorProfileImageUrl,
32 | textOriginal,
33 | publishedAt,
34 | likeCount,
35 | }
36 | return reply;
37 | }
38 |
39 | class Youtube {
40 | constructor(auth) {
41 | this.auth = auth;
42 | this.youtube = googleForever().youtube({
43 | version: 'v3',
44 | auth
45 | })
46 | }
47 |
48 | async fetchCommentCount(videoId) {
49 | const response = await this.youtube.videos.list({
50 | id: videoId,
51 | part: "statistics"
52 | })
53 |
54 | return response?.data?.items?.[0]?.statistics?.commentCount || 0
55 | }
56 |
57 | async fetchVideo(videoId) {
58 | const response = await this.youtube.videos.list({
59 | id: videoId,
60 | part: "snippet,statistics,contentDetails,status"
61 | })
62 |
63 | const [video] = response?.data?.items || []
64 | console.log(video)
65 | if (!video) {
66 | return { error: "Video doesn't exist." };
67 | }
68 |
69 | if (video.status.privacyStatus === 'private') {
70 | return { error: "Video is private." }
71 | }
72 |
73 | if (!video.status.embeddable) {
74 | return { error: "Video is not embeddable." }
75 | }
76 |
77 | const someLikes = Math.floor(Math.random() * (50 - 10 + 1)) + 10;
78 | return {
79 | video: {
80 | objectID: videoId,
81 | title: video.snippet.title,
82 | recordingDate: dayjs(video.snippet.publishedAt).toDate(),
83 | channelId: video.snippet.channelId,
84 | likes: parseInt(video.statistics.likeCount) || someLikes,
85 | channelTitle: video.snippet.channelTitle,
86 | duration: video.contentDetails.duration,
87 | submissionDate: new Date(),
88 | }
89 | }
90 | }
91 |
92 | async fetchReplies(commentId) {
93 | const response = await this.youtube.comments.list({
94 | parentId: commentId,
95 | maxResults: 50,
96 | part: "snippet"
97 | })
98 | return response?.data?.items?.map(mapCommentReply)
99 | }
100 |
101 | async replyToComment(parentId, text) {
102 | const response = await this.youtube.comments.insert({
103 | part: "snippet",
104 | requestBody: {
105 | snippet: {
106 | parentId,
107 | textOriginal: text
108 | }
109 | }
110 | })
111 | return mapCommentReply(response.data)
112 | }
113 |
114 | async addComment(videoId, text) {
115 | const response = await this.youtube.commentThreads.insert({
116 | part: "snippet",
117 | requestBody: {
118 | snippet: {
119 | videoId,
120 | topLevelComment: {
121 | snippet: {
122 | textOriginal: text
123 | }
124 | }
125 | }
126 | }
127 | })
128 | return mapComment(response.data)
129 | }
130 |
131 | async fetchComments(videoId, pageToken = undefined) {
132 | const response = await this.youtube.commentThreads.list({
133 | pageToken,
134 | videoId,
135 | maxResults: 50,
136 | order: "relevance",
137 | part: "snippet"
138 | })
139 |
140 | const nextPageToken = response?.data?.nextPageToken;
141 | const comments = response?.data?.items?.map(mapComment);
142 |
143 | return {
144 | nextPageToken,
145 | comments
146 | }
147 | }
148 | }
149 |
150 | module.exports.Youtube = Youtube
151 |
152 |
153 |
154 |
155 |
156 |
157 |
--------------------------------------------------------------------------------
/vue/src/Watch.vue:
--------------------------------------------------------------------------------
1 |
2 | .watch
3 | section.section(v-if="error")
4 | .container
5 | article.message.is-danger
6 | .message-header
7 | p Error
8 | .message-body {{ error }}
9 | section.section
10 | .container
11 | .columns
12 | .column
13 | .videoWrapper.shadow
14 | iframe(
15 | :src="`https://www.youtube-nocookie.com/embed/${id}`",
16 | title="Embedded video",
17 | frameborder="0",
18 | allow="autoplay; encrypted-media" allowfullscreen
19 | )
20 | .columns(v-if="video")
21 | .column
22 | span.has-text-grey.title.is-size-7
23 | span.mr-4 {{ video.duration | durationFull }}
24 | span.mr-3 {{ video.recordingDate | published }}
25 | WatchingNow(:video="video" v-if="video.objectID" :minimumWatching="1")
26 | span(v-if="video.series")
27 | br
28 | router-link.mr-4(v-for="(serie, index) in video.series" :key="serie" :to="{ name: 'video', params: { id: serie } }" v-bind:class="{ 'has-text-grey': serie === id }") Part {{ index + 1 }}
29 | br
30 | span.mr-4(v-for="(speakerTwitter, speakerIndex) in video.speakerTwitters")
31 | router-link(:to="'/@' + speakerTwitter") {{ video.speakerNames[speakerIndex] }}
32 | .column
33 | .is-pulled-right.has-text-right
34 | VideoActions(:video="video" v-if="video.objectID")
35 | br
36 | TwitterThanks(:video="video" v-if="video.objectID")
37 | .clearfix
38 | section.section(v-if="video")
39 | .container
40 | VideoComments(:video="video" :key="video.objectID")
41 |
42 |
61 |
151 |
--------------------------------------------------------------------------------
/vue/src/VideoActions.vue:
--------------------------------------------------------------------------------
1 |
2 | .videoActions(v-bind:class="{ 'dark': darkMode }")
3 | component(v-bind:is="component" v-bind="{ video }" v-on:close="component = ''")
4 | .buttons.are-small
5 | // Like
6 | button.button(@click="like()" v-bind:class="{ 'is-loading': wip === 'liking'}")
7 | span.icon
8 | font-awesome-icon.has-text-danger(:icon="['far', 'heart']")
9 | span {{video.likes}}
10 |
11 | // Watch later
12 | button.button(v-if="listedIn('later')" @click="removeFrom('later')" v-bind:class="{ 'is-loading': wip === 'later'}")
13 | span.icon
14 | font-awesome-icon.has-text-info(:icon="['fas', 'clock']" title="remove from watch later")
15 | span.is-hidden-mobile later
16 | button.button(v-else @click="addTo('later')" v-bind:class="{ 'is-loading': wip === 'later' }")
17 | span.icon
18 | font-awesome-icon(:icon="['far', 'clock']" title="watch later")
19 | span.is-hidden-mobile later
20 |
21 | // Watched
22 | button.button(v-if="listedIn('watched')" @click="removeFrom('watched')" v-bind:class="{ 'is-loading': wip === 'watched' }")
23 | span.icon
24 | font-awesome-icon.has-text-primary(:icon="['fas', 'check-circle']" title="remove from watched")
25 | span.is-hidden-mobile watched
26 | button.button(v-else @click="addTo('watched')" v-bind:class="{ 'is-loading': wip === 'watched' }")
27 | span.icon
28 | font-awesome-icon(:icon="['far', 'check-circle']" title="add to watched")
29 | span.is-hidden-mobile watched
30 |
31 | // Favorites
32 | button.button(v-if="listedIn('favorites')" @click="removeFrom('favorites')" v-bind:class="{ 'is-loading': wip === 'favorites' }")
33 | span.icon
34 | font-awesome-icon.has-text-warning(:icon="['fas', 'star']" title="remove from favorites")
35 | span.is-hidden-mobile favorites
36 | button.button(v-else @click="addTo('favorites')" v-bind:class="{ 'is-loading': wip === 'favorites' }")
37 | span.icon
38 | font-awesome-icon(:icon="['far', 'star']" title="add to favorites")
39 | span.is-hidden-mobile favorites
40 |
41 | // Edit
42 | button.button(v-if="isAdmin()" @click="editVideo()")
43 | span.icon
44 | font-awesome-icon(:icon="['far', 'edit']")
45 | span edit
46 |
47 | // Delete
48 | button.button(v-if="isAdmin()" @click="deleteVideo()" v-bind:class="{ 'is-loading': wip === 'deleting' }")
49 | span.icon
50 | font-awesome-icon(:icon="['fa', 'times']")
51 | span delete
52 |
53 | // Tweet
54 | button.button(v-if="isAdmin()" @click="tweetVideo()" v-bind:class="{ 'is-loading': wip === 'tweeting' }")
55 | span.icon
56 | font-awesome-icon(:icon="['fab', 'twitter']")
57 | span tweet
58 |
59 |
70 |
146 |
--------------------------------------------------------------------------------
/vue/src/Search.vue:
--------------------------------------------------------------------------------
1 |
2 | section.section.pt-0
3 | loading(:active.sync="isLoading")
4 | aside.container.pb-6
5 | .columns.is-mobile.is-multiline.is-vcentered
6 | .column.column.is-four-fifths.is-full-tablet
7 | h1.title.is-1.is-size-3-mobile(style="letter-spacing: -2px") {{title}}
8 | .column.is-full-mobile
9 | .is-pulled-right
10 | .select.is-small(@change='sortingChanged()')
11 | select(aria-label='Sorting' v-model="sorting")
12 | option(value='recent') Newest first
13 | option(value='likes') Most liked first
14 | .is-pulled-left
15 | .is-hidden-tablet.is-size-7
16 | a.has-text-grey-dark(@click='toggleCategories()' v-if='categoriesVisible' rel="noopener noreferrer nofollow")
17 | font-awesome-icon(:icon="['far', 'eye-slash']")
18 | | Hide categories
19 | .is-hidden-tablet.is-size-7
20 | a.has-text-grey-dark(@click='toggleCategories()' v-if='!categoriesVisible' rel="noopener noreferrer nofollow")
21 | font-awesome-icon(:icon="['far', 'eye']")
22 | | Show categories
23 | main.container.mt-6
24 | .columns
25 | .column.is-4
26 | Categories.mb-6(:class="{ 'is-hidden-mobile': !forceShowCategories }"
27 | v-observe-visibility="categoryVisibilityChanged")
28 | .column.is-8
29 | VideoCard.is-12(v-for="video in videos"
30 | :speakerIndex="speakerIndex"
31 | :video="video" :key="video.objectID"
32 | )
33 | button.button.is-small(v-if="more" @click="showMore()") More
34 | button.button.submit-video.is-info(@click="submitVideo()") Submit a talk
35 | component(v-bind:is="component" v-on:close="component = ''")
36 |
37 |
75 |
172 |
--------------------------------------------------------------------------------
/vue/public/privacy.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 |
9 |
10 |
11 |
12 |
Privacy Policy
13 |
Eduards Sizovs has built DevTube as an [open
14 | source](https://github.com/watch-devtube/web) app and its code is publicly available for the inspection. This
15 | SERVICE is provided at no cost and is intended for use as is.
16 |
This page is used to inform website visitors regarding our policies with the collection, use, and disclosure of
17 | Personal Information if anyone decided to use our Service.
18 |
If you choose to use our Service, then you agree to the collection and use of information in relation to this
19 | policy.
20 | The Personal Information that we collect is used for providing and improving the Service. We will not use or share
21 | your information with anyone except as described in this Privacy Policy.
22 |
The terms used in this Privacy Policy have the same meanings as in our Terms and Conditions, which is accessible
23 | at
24 | DevTube unless otherwise defined in this Privacy Policy.
25 |
26 |
For better experience, while using our Service, we may require you to provide us with certain personally
27 | identifiable
28 | information, including but not limited to email, first name, last name, contacts. The information that we request
29 | is
30 | will be retained by us and used as described in this privacy policy.
31 |
Cookies
32 |
Cookies are files with a small amount of data that are commonly used as anonymous unique identifiers. These are
33 | sent
34 | to your browser from the websites that you visit and are stored on your device's internal memory. This service
35 | uses cookies to improve your UX. This includes authentication, sorting preference, night mode, and resuming video
36 | from
37 | the last played position. The app may use third party code and libraries that use “cookies” to collect information
38 | and
39 | improve their services. You have the option control cookies in your browser. If you choose to refuse our cookies,
40 | you
41 | may not be able to use some portions of this Service.
42 |
Service Providers
43 |
We may employ third-party companies and individuals due to the following reasons:
44 |
45 | To facilitate our Service;
46 | To provide the Service on our behalf;
47 | To perform Service-related services; or
48 | To assist us in analyzing how our Service is used.
49 |
50 |
We want to inform users of this Service that these third parties have access to your Personal Information. The
51 | reason
52 | is to perform the tasks assigned to them on our behalf. However, they are obligated not to disclose or use the
53 | information for any other purpose.
54 |
Security
55 |
We value your trust in providing us your Personal Information, thus we are striving to use commercially
56 | acceptable
57 | means of protecting it. But remember that no method of transmission over the internet, or method of electronic
58 | storage
59 | is 100% secure and reliable, and we cannot guarantee its absolute security.
60 |
Links to Other Sites
61 |
Service may contain links to other sites. If you click on a third-party link, you will be directed to that site.
62 | Note
63 | that these external sites are not operated by us. Therefore, we strongly advise you to review the Privacy Policy
64 | of
65 | these websites. We have no control over and assume no responsibility for the content, privacy policies, or
66 | practices
67 | of any third-party sites or services.
68 |
Children’s Privacy
69 |
These Services do not address anyone under the age of 13. We do not knowingly collect personally identifiable
70 | information from children under 13. In the case we discover that a child under 13 has provided us with personal
71 | information, we immediately delete this from our servers. If you are a parent or guardian and you are aware that
72 | your
73 | child has provided us with personal information, please contact us so that we will be able to do necessary
74 | actions.
75 |
76 |
Changes to This Privacy Policy
77 |
We may update our Privacy Policy from time to time. Thus, you are advised to review this page periodically for
78 | any
79 | changes. We will notify you of any changes by posting the new Privacy Policy on this page. These changes are
80 | effective
81 | immediately after they are posted on this page.
82 |
YouTube/Google Privacy Policy
83 |
A user may choose to grant DevTube access to their Google user data by authenticating via Google/Youtube via the
84 | standard OAuth Consent Screen.
85 |
DevTube will then retrieve the username, email, avatar, OAuth Access Token, and OAuth Refresh Token and store
86 | them
87 | securely in the corresponding user browser's Cookies, encrypted with the AES-256 algorithm. The basic user data –
88 | username, email, and avatar – are used to identify the user. The sensitive data – OAuth token – is used to let the
89 | user comment on YouTube videos directly from the DevTube. Because OAuth tokens automatically expire in 7 days, we
90 | use
91 | OAuth Refresh Token to refresh the Access Token after its expiration. DevTube never shares Google user data
92 | with
93 | a
94 | 3rd party and the use of Google user data is strictly limited to what's
95 | described in this privacy policy.
96 |
97 |
DevTube contains data extracted from YouTube API. Related privacy policies:
98 |
102 |
103 |
If you have any questions or suggestions about our Privacy Policy, do not hesitate to contact us at
104 | hello+policy@dev.tube
105 |
106 |
107 |
108 |
109 |
--------------------------------------------------------------------------------
/express/src/libs/Cookies.js:
--------------------------------------------------------------------------------
1 | const crypto = require("crypto");
2 | const algorithm = 'aes-256-ctr';
3 | const iv = crypto.randomBytes(16);
4 |
5 | const { COOKIE_SECRET } = process.env;
6 |
7 | const encrypt = (text) => {
8 | const cipher = crypto.createCipheriv(algorithm, COOKIE_SECRET, iv);
9 | const encrypted = Buffer.concat([cipher.update(text), cipher.final()]);
10 |
11 | return iv.toString('hex') + "----" + encrypted.toString('hex')
12 | };
13 |
14 | const decrypt = (hash) => {
15 | const [iv, content] = hash.split("----");
16 | const decipher = crypto.createDecipheriv(algorithm, COOKIE_SECRET, Buffer.from(iv, 'hex'));
17 | const decrypted = Buffer.concat([decipher.update(Buffer.from(content, 'hex')), decipher.final()]);
18 | return decrypted.toString();
19 | };
20 |
21 | var Buffer = require('safe-buffer').Buffer
22 | var debug = require('debug')('cookie-session')
23 | var Cookies = require('cookies')
24 | var onHeaders = require('on-headers')
25 |
26 | module.exports = cookieSession
27 |
28 | /**
29 | * Create a new cookie session middleware.
30 | *
31 | * @param {object} [options]
32 | * @param {boolean} [options.httpOnly=true]
33 | * @param {array} [options.keys]
34 | * @param {string} [options.name=session] Name of the cookie to use
35 | * @param {boolean} [options.overwrite=true]
36 | * @param {string} [options.secret]
37 | * @param {boolean} [options.signed=true]
38 | * @return {function} middleware
39 | * @public
40 | */
41 |
42 | function cookieSession(options) {
43 | var opts = options || {}
44 |
45 | // cookie name
46 | var name = opts.name || 'session'
47 |
48 | // secrets
49 | var keys = opts.keys
50 | if (!keys && opts.secret) keys = [opts.secret]
51 |
52 | // defaults
53 | if (opts.overwrite == null) opts.overwrite = true
54 | if (opts.httpOnly == null) opts.httpOnly = true
55 | if (opts.signed == null) opts.signed = true
56 |
57 | if (!keys && opts.signed) throw new Error('.keys required.')
58 |
59 | debug('session options %j', opts)
60 |
61 | return function _cookieSession(req, res, next) {
62 | var cookies = new Cookies(req, res, {
63 | keys: keys
64 | })
65 | var sess
66 |
67 | // for overriding
68 | req.sessionOptions = Object.create(opts)
69 |
70 | // define req.session getter / setter
71 | Object.defineProperty(req, 'session', {
72 | configurable: true,
73 | enumerable: true,
74 | get: getSession,
75 | set: setSession
76 | })
77 |
78 | function getSession() {
79 | // already retrieved
80 | if (sess) {
81 | return sess
82 | }
83 |
84 | // unset
85 | if (sess === false) {
86 | return null
87 | }
88 |
89 | // get session
90 | if ((sess = tryGetSession(cookies, name, req.sessionOptions))) {
91 | return sess
92 | }
93 |
94 | // create session
95 | debug('new session')
96 | return (sess = Session.create())
97 | }
98 |
99 | function setSession(val) {
100 | if (val == null) {
101 | // unset session
102 | sess = false
103 | return val
104 | }
105 |
106 | if (typeof val === 'object') {
107 | // create a new session
108 | sess = Session.create(val)
109 | return sess
110 | }
111 |
112 | throw new Error('req.session can only be set as null or an object.')
113 | }
114 |
115 | onHeaders(res, function setHeaders() {
116 | if (sess === undefined) {
117 | // not accessed
118 | return
119 | }
120 |
121 | try {
122 | if (sess === false) {
123 | // remove
124 | debug('remove %s', name)
125 | cookies.set(name, '', req.sessionOptions)
126 | } else if ((!sess.isNew || sess.isPopulated) && sess.isChanged) {
127 | // save populated or non-new changed session
128 | debug('save %s', name)
129 | cookies.set(name, Session.serialize(sess), req.sessionOptions)
130 | }
131 | } catch (e) {
132 | debug('error saving session %s', e.message)
133 | }
134 | })
135 |
136 | next()
137 | }
138 | };
139 |
140 | /**
141 | * Session model.
142 | *
143 | * @param {Context} ctx
144 | * @param {Object} obj
145 | * @private
146 | */
147 |
148 | function Session(ctx, obj) {
149 | Object.defineProperty(this, '_ctx', {
150 | value: ctx
151 | })
152 |
153 | if (obj) {
154 | for (var key in obj) {
155 | this[key] = obj[key]
156 | }
157 | }
158 | }
159 |
160 | /**
161 | * Create new session.
162 | * @private
163 | */
164 |
165 | Session.create = function create(obj) {
166 | var ctx = new SessionContext()
167 | return new Session(ctx, obj)
168 | }
169 |
170 | /**
171 | * Create session from serialized form.
172 | * @private
173 | */
174 |
175 | Session.deserialize = function deserialize(str) {
176 | var ctx = new SessionContext()
177 | var obj = decode(str)
178 |
179 | ctx._new = false
180 | ctx._val = str
181 |
182 | return new Session(ctx, obj)
183 | }
184 |
185 | /**
186 | * Serialize a session to a string.
187 | * @private
188 | */
189 |
190 | Session.serialize = function serialize(sess) {
191 | return encode(sess)
192 | }
193 |
194 | /**
195 | * Return if the session is changed for this request.
196 | *
197 | * @return {Boolean}
198 | * @public
199 | */
200 |
201 | Object.defineProperty(Session.prototype, 'isChanged', {
202 | get: function getIsChanged() {
203 | return this._ctx._new || this._ctx._val !== Session.serialize(this)
204 | }
205 | })
206 |
207 | /**
208 | * Return if the session is new for this request.
209 | *
210 | * @return {Boolean}
211 | * @public
212 | */
213 |
214 | Object.defineProperty(Session.prototype, 'isNew', {
215 | get: function getIsNew() {
216 | return this._ctx._new
217 | }
218 | })
219 |
220 | /**
221 | * populated flag, which is just a boolean alias of .length.
222 | *
223 | * @return {Boolean}
224 | * @public
225 | */
226 |
227 | Object.defineProperty(Session.prototype, 'isPopulated', {
228 | get: function getIsPopulated() {
229 | return Object.keys(this).length > 0
230 | }
231 | })
232 |
233 | /**
234 | * Session context to store metadata.
235 | *
236 | * @private
237 | */
238 |
239 | function SessionContext() {
240 | this._new = true
241 | this._val = undefined
242 | }
243 |
244 | /**
245 | * Decode the base64 cookie value to an object.
246 | *
247 | * @param {String} string
248 | * @return {Object}
249 | * @private
250 | */
251 |
252 | function decode(string) {
253 | return JSON.parse(decrypt(string))
254 | }
255 |
256 | /**
257 | * Encode an object into a base64-encoded JSON string.
258 | *
259 | * @param {Object} body
260 | * @return {String}
261 | * @private
262 | */
263 |
264 | function encode(body) {
265 | return encrypt(JSON.stringify(body));
266 | // return Buffer.from(str).toString('base64')
267 | }
268 |
269 | /**
270 | * Try getting a session from a cookie.
271 | * @private
272 | */
273 |
274 | function tryGetSession(cookies, name, opts) {
275 | var str = cookies.get(name, opts)
276 |
277 | if (!str) {
278 | return undefined
279 | }
280 |
281 | debug('parse %s', str)
282 |
283 | try {
284 | return Session.deserialize(str)
285 | } catch (err) {
286 | return undefined
287 | }
288 | }
289 |
--------------------------------------------------------------------------------
/vue/src/SubmitVideo.vue:
--------------------------------------------------------------------------------
1 |
2 | .modal.is-active
3 | .modal-background
4 | .modal-content
5 | .columns.is-mobile
6 | .column.is-10.is-offset-1.window
7 | h1.title.has-text-weight-bold.is-4.has-text-centered Submit a talk to DevTube
8 | p.has-text-centered
9 | font-awesome-icon(:icon="['far', 'heart']").has-text-danger
10 | span You'll get karma points if the talk gets published (and for every like the talk gets).
11 | br
12 | .beforeFetching(v-if="!video.objectID")
13 | .columns
14 | .column
15 | .field
16 | label.label Video URL
17 | .control(v-bind:class="{ 'is-loading': isLoading }")
18 | input.input(type="text", v-model="url" @input="urlChanged()" :disabled="isLoading" placeholder="https://youtube.com/watch?v=AEtCEt44vlE?x")
19 | p.help(v-if="isLoading") Fetching video from YouTube...
20 | p.help.has-text-danger(v-if="error") {{error}}
21 | .whenFetchingDone(v-else)
22 | .columns
23 | .column
24 | .field
25 | .control
26 | input.input(type="text", v-model="video.title" placeholder="Title")
27 | p.help {{video.duration | durationFull}} • {{video.recordingDate | year}} • {{video.channelTitle}}
28 | .columns
29 | .column
30 | .field.is-grouped.is-grouped-multiline
31 | .control(v-for="(speakerName, index) in video.speakerNames")
32 | .tags.has-addons
33 | span.tag.is-medium {{speakerName}}
34 | a.tag.is-delete.is-medium(@click="removeSpeaker(index)")
35 | .columns
36 | .column
37 | .field
38 | .control
39 | input.input(type="text", v-model="newSpeakerName" placeholder="Elon Musk")
40 | p.help(v-if="suggestedSpeaker")
41 | a(@click="addSpeaker(suggestedSpeaker.name, suggestedSpeaker.twitter)")
42 | font-awesome-icon(:icon="['fa', 'plus']")
43 | | {{suggestedSpeaker.name}}
44 | .columns
45 | .column
46 | .field
47 | .control.has-icons-left
48 | input.input(type="text", v-model="newSpeakerTwitter" placeholder="elonmusk")
49 | .icon.is-small.is-left
50 | font-awesome-icon(:icon="['fab', 'twitter']")
51 | button.button.is-pulled-right.is-text(@click="addSpeaker(newSpeakerName, newSpeakerTwitter)") Add speaker
52 | .columns
53 | .column
54 | .field.is-grouped.is-grouped-multiline
55 | .control(v-for="(topic, index) in video.topics" :key="topic")
56 | .tags.has-addons
57 | span.tag.is-medium {{topic}}
58 | a.tag.is-delete.is-medium(@click="removeTopic(index)")
59 | .columns
60 | .column
61 | .field
62 | .control.has-icons-left
63 | input.input(type="text" placeholder="Future" v-model="newTopic" v-on:keyup.enter="addTopic(newTopic)")
64 | .icon.is-small.is-left
65 | font-awesome-icon(:icon="['fa', 'hashtag']")
66 | p.help(v-if="suggestedTopic")
67 | a(@click="addTopic(suggestedTopic)")
68 | font-awesome-icon(:icon="['fa', 'plus']")
69 | | {{suggestedTopic}}
70 | button.button.is-pulled-right.is-text(@click="addTopic(newTopic)") Add topic
71 | .columns
72 | .column
73 | .field
74 | .control
75 | .buttons.is-pulled-right
76 | button.button.is-success(@click="submitVideo()" v-bind:class="{'is-loading': isSaving}" :disabled="isSubmitButtonDisabled") Submit
77 | button.button(@click="close()") Cancel
78 | .modal-close.is-large(aria-label="close" @click="close()")
79 |
80 |
83 |
88 |
207 |
--------------------------------------------------------------------------------
/vue/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
147 |
272 |
273 |
304 |
305 |
306 |
307 |
308 |
309 |
310 | Hi. I am error 404.
311 |
312 |
315 |
316 |
317 |
318 |
319 |
Try again from DevTube . ... and follow us on Twitter
321 |
322 |
323 |
324 |
325 |
326 |
436 |
437 |
438 |
441 |
442 |
443 |
444 |
445 |
--------------------------------------------------------------------------------