diff --git a/api/channels.js b/api/channels.js index 3337635..5c3aff5 100644 --- a/api/channels.js +++ b/api/channels.js @@ -7,8 +7,9 @@ module.exports = [ { route: `/channel/(${constants.regex.ucid})`, methods: ["GET"], code: async ({req, fill}) => { const id = fill[0] - const data = await fetchChannel(id) const user = getUser(req) + const settings = user.getSettingsOrDefaults() + const data = await fetchChannel(id, settings.instance) const subscribed = user.isSubscribed(id) return render(200, "pug/channel.pug", {data, subscribed}) } diff --git a/api/settings.js b/api/settings.js new file mode 100644 index 0000000..21ed293 --- /dev/null +++ b/api/settings.js @@ -0,0 +1,67 @@ +const {render, redirect} = require("pinski/plugins") +const db = require("./utils/db") +const {getToken, getUser} = require("./utils/getuser") +const constants = require("./utils/constants") +const validate = require("./utils/validate") +const V = validate.V + +module.exports = [ + { + route: "/settings", methods: ["GET"], code: async ({req}) => { + const user = getUser(req) + const settings = user.getSettings() + return render(200, "pug/settings.pug", {constants, settings}) + } + }, + { + route: "/settings", methods: ["POST"], upload: true, code: async ({req, body}) => { + return new V() + .with(validate.presetLoad({body})) + .with(validate.presetURLParamsBody()) + .last(async state => { + const {params} = state + const responseHeaders = { + Location: "/settings" + } + const token = getToken(req, responseHeaders) + const data = {} + + for (const key of Object.keys(constants.user_settings)) { + const setting = constants.user_settings[key] + if (params.has(key)) { + const provided = params.get(key) + if (setting.type === "string") { + if (provided) data[key] = provided + else data[key] = null + } else if (setting.type === "integer") { + if (isNaN(provided)) data[key] = null + else data[key] = +provided + } else if (setting.type === "boolean") { + if (provided === "true") data[key] = true + else if (provided === "false") data[key] = false + else data[key] = null + } else { + throw new Error("Unsupported setting type: "+setting.type) + } + } else { + data[key] = null + } + } + + db.prepare("DELETE FROM Settings WHERE token = ?").run(token) + const keys = ["token", ...Object.keys(constants.user_settings)] + const baseFields = keys.join(", ") + const atFields = keys.map(k => "@"+k).join(", ") + db.prepare(`INSERT INTO Settings (${baseFields}) VALUES (${atFields})`).run({token, ...data}) + + return { + statusCode: 303, + headers: responseHeaders, + contentType: "text/html", + content: "Redirecting..." + } + }) + .go() + } + } +] diff --git a/api/utils/constants.js b/api/utils/constants.js index 3a1dbbd..c274a44 100644 --- a/api/utils/constants.js +++ b/api/utils/constants.js @@ -1,4 +1,15 @@ const constants = { + user_settings: { + instance: { + type: "string", + default: "https://invidious.snopyta.org" + }, + save_history: { + type: "boolean", + default: false + } + }, + caching: { csrf_time: 4*60*60*1000 }, diff --git a/api/utils/getuser.js b/api/utils/getuser.js index e4476aa..92d147d 100644 --- a/api/utils/getuser.js +++ b/api/utils/getuser.js @@ -23,6 +23,22 @@ class User { this.token = token } + getSettings() { + if (this.token) { + return db.prepare("SELECT * FROM Settings WHERE token = ?").get(this.token) || {} + } else { + return {} + } + } + + getSettingsOrDefaults() { + const settings = this.getSettings() + for (const key of Object.keys(settings)) { + if (settings[key] === null) settings[key] = constants.user_settings[key].default + } + return settings + } + getSubscriptions() { if (this.token) { return db.prepare("SELECT ucid FROM Subscriptions WHERE token = ?").pluck().all(this.token) diff --git a/api/utils/upgradedb.js b/api/utils/upgradedb.js index 15b4bd5..11c59b8 100644 --- a/api/utils/upgradedb.js +++ b/api/utils/upgradedb.js @@ -10,8 +10,14 @@ const deltas = [ .run() db.prepare("CREATE TABLE Channels (ucid TEXT NOT NULL, name TEXT NOT NULL, icon_url TEXT, PRIMARY KEY (ucid))") .run() + db.prepare("CREATE TABLE Videos (videoId TEXT NOT NULL, title TEXT NOT NULL, author TEXT, authorId TEXT NOT NULL, published INTEGER, publishedText TEXT, lengthText TEXT, viewCountText TEXT, descriptionHtml TEXT, PRIMARY KEY (videoId))") + .run() db.prepare("CREATE TABLE CSRFTokens (token TEXT NOT NULL, expires INTEGER NOT NULL, PRIMARY KEY (token))") .run() + db.prepare("CREATE TABLE SeenTokens (token TEXT NOT NULL, seen INTEGER NOT NULL, PRIMARY KEY (token))") + .run() + db.prepare("CREATE TABLE Settings (token TEXT NOT NULL, instance TEXT, save_history INTEGER, PRIMARY KEY (token))") + .run() } ] diff --git a/api/utils/youtube.js b/api/utils/youtube.js index 7a568a3..2a41467 100644 --- a/api/utils/youtube.js +++ b/api/utils/youtube.js @@ -1,9 +1,9 @@ const fetch = require("node-fetch") const db = require("./db") -async function fetchChannel(ucid) { +async function fetchChannel(ucid, instance) { // fetch - const channel = await fetch(`http://localhost:3000/api/v1/channels/${ucid}`).then(res => res.json()) + const channel = await fetch(`${instance}/api/v1/channels/${ucid}`).then(res => res.json()) // update database const bestIcon = channel.authorThumbnails.slice(-1)[0] const iconURL = bestIcon ? bestIcon.url : null diff --git a/api/video.js b/api/video.js index cafdf6d..8aab11d 100644 --- a/api/video.js +++ b/api/video.js @@ -1,19 +1,48 @@ const fetch = require("node-fetch") const {render} = require("pinski/plugins") const db = require("./utils/db") -const {getToken} = require("./utils/getuser") +const {getToken, getUser} = require("./utils/getuser") +const pug = require("pug") + +class InstanceError extends Error { + constructor(error, identifier) { + super(error) + this.identifier = identifier + } +} module.exports = [ { route: "/watch", methods: ["GET"], code: async ({req, url}) => { const id = url.searchParams.get("v") - const video = await fetch(`http://localhost:3000/api/v1/videos/${id}`).then(res => res.json()) - let subscribed = false - const token = getToken(req) - if (token) { - subscribed = !!db.prepare("SELECT * FROM Subscriptions WHERE token = ? AND ucid = ?").get([token, video.authorId]) + const user = getUser(req) + const settings = user.getSettingsOrDefaults() + const outURL = `${settings.instance}/api/v1/videos/${id}` + try { + const video = await fetch(outURL).then(res => res.json()) + if (!video) throw new Error("The instance returned null.") + if (video.error) throw new InstanceError(video.error, video.identifier) + const subscribed = user.isSubscribed(video.authorId) + return render(200, "pug/video.pug", {video, subscribed}) + } catch (e) { + let message = pug.render("pre= error", {error: e.stack || e.toString()}) + if (e instanceof fetch.FetchError) { + const template = ` +p The selected instance, #[code= instance], did not respond correctly. +p Requested URL: #[a(href=url)= url] +` + message = pug.render(template, {instance: settings.instance, url: outURL}) + } else if (e instanceof InstanceError) { + const template = ` +p #[strong= error.message] +if error.identifier + p #[code= error.identifier] +p That error was generated by #[code= instance]. +` + message = pug.render(template, {instance: settings.instance, error: e}) + } + return render(500, "pug/video.pug", {video: {videoId: id}, error: true, message}) } - return render(200, "pug/video.pug", {video, subscribed}) } } ] diff --git a/background/feed-update.js b/background/feed-update.js new file mode 100644 index 0000000..d1824ad --- /dev/null +++ b/background/feed-update.js @@ -0,0 +1,2 @@ +const db = require("../api/utils/db") + diff --git a/html/static/images/settings.svg b/html/static/images/settings.svg new file mode 100644 index 0000000..50c90a9 --- /dev/null +++ b/html/static/images/settings.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/html/static/js/player.js b/html/static/js/player.js index 3421750..9576162 100644 --- a/html/static/js/player.js +++ b/html/static/js/player.js @@ -49,6 +49,7 @@ class FormatLoader { video.currentTime = lastTime if (this.npa) { audio.src = this.npa.url + audio.pause() audio.currentTime = lastTime } else { audio.pause() @@ -59,6 +60,30 @@ class FormatLoader { const formatLoader = new FormatLoader() +class PlayManager { + constructor(media, isAudio) { + this.media = media + this.isAudio = isAudio + } + + isActive() { + return !this.isAudio || formatLoader.npa + } + + play() { + if (this.isActive()) this.media.play() + } + + pause() { + if (this.isActive()) this.media.pause() + } +} + +const playManagers = { + video: new PlayManager(video, false), + audio: new PlayManager(audio, true) +} + class QualitySelect extends ElemJS { constructor() { super(q("#quality-select")) @@ -74,12 +99,21 @@ class QualitySelect extends ElemJS { const qualitySelect = new QualitySelect() +const ignoreNext = { + play: 0 +} + function playbackIntervention(event) { console.log(event.target.tagName.toLowerCase(), event.type) if (audio.src) { let target = event.target - let targetName = target.tagName.toLowerCase() let other = (event.target === video ? audio : video) + let targetPlayManager = playManagers[target.tagName.toLowerCase()] + let otherPlayManager = playManagers[other.tagName.toLowerCase()] + if (ignoreNext[event.type] > 0) { + ignoreNext[event.type]-- + return + } switch (event.type) { case "durationchange": target.ready = false; @@ -91,7 +125,7 @@ function playbackIntervention(event) { break; case "play": other.currentTime = target.currentTime; - other.play(); + otherPlayManager.play(); break; case "pause": other.currentTime = target.currentTime; @@ -125,13 +159,44 @@ function relativeSeek(seconds) { video.currentTime += seconds } +function playVideo() { + audio.currentTime = video.currentTime + let lastTime = video.currentTime + video.play().then(() => { + const interval = setInterval(() => { + console.log("checking video", video.currentTime, lastTime) + if (video.currentTime !== lastTime) { + clearInterval(interval) + playManagers.audio.play() + return + } + }, 15) + }) +} + function togglePlaying() { - if (video.paused) video.play() + if (video.paused) playVideo() else video.pause() } +function toggleFullScreen() { + if (document.fullscreen) document.exitFullscreen() + else video.requestFullscreen() +} + +video.addEventListener("click", event => { + event.preventDefault() + togglePlaying() +}) + +video.addEventListener("dblclick", event => { + event.preventDefault() + toggleFullScreen() +}) + document.addEventListener("keydown", event => { if (["INPUT", "SELECT", "BUTTON"].includes(event.target.tagName)) return + if (event.ctrlKey || event.shiftKey) return let caught = true if (event.key === "j" || event.key === "n") { relativeSeek(-10) @@ -148,8 +213,7 @@ document.addEventListener("keydown", event => { } else if (event.key >= "0" && event.key <= "9") { video.currentTime = video.duration * (+event.key) / 10 } else if (event.key === "f") { - if (document.fullscreen) document.exitFullscreen() - else video.requestFullscreen() + toggleFullScreen() } else { caught = false } diff --git a/pug/includes/layout.pug b/pug/includes/layout.pug index 19d13a8..20d4f13 100644 --- a/pug/includes/layout.pug +++ b/pug/includes/layout.pug @@ -10,8 +10,10 @@ html body.show-focus nav.main-nav a(href="/").link.home CloudTube - a(href="/subscriptions" title="Subscriptions").link.subscriptions-link - img(src=getStaticURL("html", "/static/images/subscriptions.svg") width=30 height=25 alt="Subscriptions.").subscriptions-icon + a(href="/subscriptions" title="Subscriptions").link.icon-link + img(src=getStaticURL("html", "/static/images/subscriptions.svg") width=30 height=25 alt="Subscriptions.").icon + a(href="/settings" title="Settings").link.icon-link + img(src=getStaticURL("html", "/static/images/settings.svg") width=25 height=25 alt="Settings.").icon form(method="get" action="/search").search-form input(type="text" placeholder="Search" name="q" autocomplete="off" value=query).search diff --git a/pug/includes/video-list-item.pug b/pug/includes/video-list-item.pug index 3ca9b09..b3d75c7 100644 --- a/pug/includes/video-list-item.pug +++ b/pug/includes/video-list-item.pug @@ -2,7 +2,8 @@ mixin video_list_item(video) - let link = `/watch?v=${video.videoId}` a(href=link tabindex="-1").thumbnail img(src=`https://i.ytimg.com/vi/${video.videoId}/mqdefault.jpg` width=320 height=180 alt="").image - span.duration= video.second__lengthText + if video.second__lengthText !== undefined + span.duration= video.second__lengthText .info div.title: a(href=link).title-link= video.title div.author-line diff --git a/pug/settings.pug b/pug/settings.pug new file mode 100644 index 0000000..ec5c5c0 --- /dev/null +++ b/pug/settings.pug @@ -0,0 +1,46 @@ +extends includes/layout.pug + +mixin fieldset(name) + fieldset + legend= name + .fieldset-contents + block + +mixin input(id, description, placeholder, disabled, list) + .field-row + label.description(for=id)= description + input(type="text" id=id name=id value=settings[id] placeholder=placeholder disabled=disabled list=`${id}-list`).border-look + if list + datalist(id=`${id}-list`) + each item in list + option(value=item) + +mixin select(id, description, disabled, options) + .field-row + label.description(for=id)= description + select(id=id name=id disabled=disabled).border-look + each option in options + option(value=option.value selected=(option.value === settings[id]))= option.text + +block head + title Settings - CloudTube + +block content + main.settings-page + form(method="post" action="/settings") + +fieldset("Settings") + + +input("instance", "Instance", constants.user_settings.instance.default, false, [ + "https://invidious.snopyta.org", + "https://invidious.13ad.de", + "https://watch.nettohikari.com", + "https://invidious.fdn.fr" + ]) + + +select("save_history", "Watch history", false, [ + {value: "", text: "Don't save"}, + {value: "yes", text: "Save"} + ]) + + .save-settings + button.border-look Save \ No newline at end of file diff --git a/pug/subscriptions.pug b/pug/subscriptions.pug index 213a15e..b0cd771 100644 --- a/pug/subscriptions.pug +++ b/pug/subscriptions.pug @@ -7,6 +7,21 @@ block head block content main.subscriptions-page - each video in videos - .subscriptions-video - +video_list_item(video) + if hasSubscriptions + section + details.channels-details + summary #{channels.length} subscriptions + .channels-list + for channel in channels + a(href=`/channel/${channel.ucid}`).channel-item + img(src=channel.icon_url width=512 height=512 alt="").thumbnail + span.name= channel.name + + each video in videos + .subscriptions-video + +video_list_item(video) + else + .no-subscriptions + h2 You have no subscriptions. + p Subscribing to a channel makes its videos appear here. + p You can find the subscribe button on channels and videos. diff --git a/pug/video.pug b/pug/video.pug index 938e885..86ca66b 100644 --- a/pug/video.pug +++ b/pug/video.pug @@ -4,58 +4,70 @@ include includes/video-list-item include includes/subscribe-button block head - title= `${video.title} - CloudTube` + unless error + title= `${video.title} - CloudTube` + else + title Error - CloudTube script(type="module" src=getStaticURL("html", "/static/js/player.js")) script const data = !{JSON.stringify(video)} block content - - const sortedFormatStreams = video.formatStreams.slice().sort((a, b) => b.second__height - a.second__height) - - const sortedVideoAdaptiveFormats = video.adaptiveFormats.filter(f => f.type.startsWith("video")).sort((a, b) => a.second__order - b.second__order) - main.video-page - .main-video-section - .video-container - - const format = sortedFormatStreams[0] - video(controls preload="auto" width=format.second__width height=format.second__height data-itag=format.itag)#video.video - source(src=format.url type=format.type) + unless error + main.video-page + - const sortedFormatStreams = video.formatStreams.slice().sort((a, b) => b.second__height - a.second__height) + - const sortedVideoAdaptiveFormats = video.adaptiveFormats.filter(f => f.type.startsWith("video")).sort((a, b) => a.second__order - b.second__order) - #current-time-container - #end-cards-container - .info - header.info-main - h1.title= video.title - .author - a(href=`/channel/${video.authorId}`).author-link= `Uploaded by ${video.author}` - .info-secondary - - const date = new Date(video.published*1000) - - const month = new Intl.DateTimeFormat("en-US", {month: "short"}).format(date.getTime()) - div= `Uploaded ${date.getUTCDate()} ${month} ${date.getUTCFullYear()}` - div= video.second__viewCountText - div(style=`--rating: ${video.rating*20}%`)#rating-bar.rating-bar + .main-video-section + .video-container + - const format = sortedFormatStreams[0] + video(controls preload="auto" width=format.second__width height=format.second__height data-itag=format.itag)#video.video + source(src=format.url type=format.type) - audio(preload="auto")#audio - #live-event-notice - #audio-loading-display + #current-time-container + #end-cards-container + .info + header.info-main + h1.title= video.title + .author + a(href=`/channel/${video.authorId}`).author-link= `Uploaded by ${video.author}` + .info-secondary + - const date = new Date(video.published*1000) + - const month = new Intl.DateTimeFormat("en-US", {month: "short"}).format(date.getTime()) + div= `Uploaded ${date.getUTCDate()} ${month} ${date.getUTCFullYear()}` + div= video.second__viewCountText + div(style=`--rating: ${video.rating*20}%`)#rating-bar.rating-bar - .video-button-container - +subscribe_button(video.authorId, subscribed, `/watch?v=${video.videoId}`).border-look - //- button.border-look#theatre Theatre - select(autocomplete="off").border-look#quality-select - each f in sortedFormatStreams - option(value=f.itag)= `${f.qualityLabel} ${f.container}` - each f in sortedVideoAdaptiveFormats - option(value=f.itag)= `${f.qualityLabel} ${f.container} *` - //- - a(href="/subscriptions").border-look - img(src="/static/images/search.svg" width=17 height=17 alt="").button-icon - | Search - //- button.border-look#share Share - a(href=`https://www.youtube.com/watch?v=${video.videoId}`).border-look YouTube - a(href=`https://invidio.us/watch?v=${video.videoId}`).border-look Invidious + audio(preload="auto")#audio + #live-event-notice + #audio-loading-display - .description!= video.descriptionHtml + .button-container + +subscribe_button(video.authorId, subscribed, `/watch?v=${video.videoId}`).border-look + //- button.border-look#theatre Theatre + select(autocomplete="off").border-look#quality-select + each f in sortedFormatStreams + option(value=f.itag)= `${f.qualityLabel} ${f.container}` + each f in sortedVideoAdaptiveFormats + option(value=f.itag)= `${f.qualityLabel} ${f.container} *` + //- + a(href="/subscriptions").border-look + img(src="/static/images/search.svg" width=17 height=17 alt="").button-icon + | Search + //- button.border-look#share Share + a(href=`https://www.youtube.com/watch?v=${video.videoId}`).border-look YouTube + a(href=`https://invidio.us/watch?v=${video.videoId}`).border-look Invidious - aside.related-videos - h2.related-header Related videos - each r in video.recommendedVideos - .related-video - +video_list_item(r) + .description!= video.descriptionHtml + + aside.related-videos + h2.related-header Related videos + each r in video.recommendedVideos + .related-video + +video_list_item(r) + + else + //- error + main.video-error-page + h2 Error + != message + p: a(href=`https://www.youtube.com/watch?v=${video.videoId}`) Watch on YouTube → \ No newline at end of file diff --git a/sass/includes/base.sass b/sass/includes/base.sass index 4bd1f2c..a2cb39d 100644 --- a/sass/includes/base.sass +++ b/sass/includes/base.sass @@ -11,6 +11,14 @@ body a color: c.$link +pre, code + font-size: 0.88em + +code + background: c.$bg-darker + padding: 3px 5px + border-radius: 4px + input, select, button font-family: inherit font-size: 16px @@ -38,3 +46,19 @@ body.show-focus video background-color: black + +details + background-color: c.$bg-accent-x + padding: 12px + border-radius: 8px + + summary + text-decoration: underline + cursor: pointer + line-height: 1 + margin-bottom: 0 + user-select: none + color: c.$fg-main + + &[open] summary + margin-bottom: 16px diff --git a/sass/includes/forms.sass b/sass/includes/forms.sass new file mode 100644 index 0000000..c8dad07 --- /dev/null +++ b/sass/includes/forms.sass @@ -0,0 +1,81 @@ +@use "colors.sass" as c + +@mixin disabled + background-color: c.$bg-dark + color: #808080 + +fieldset + border: none + padding: 55px 0px 0px 0px + position: relative + + @media screen and (max-width: 400px) + padding-top: 70px + + legend + position: absolute + top: 5px + left: 0px + width: 100% + font-size: 28px + font-weight: bold + padding: 0 + border-bottom: 1px solid #333 + line-height: 1.56 + + @media screen and (max-width: 400px) + margin-top: 15px + + +.field-row + line-height: 1 + display: flex + align-items: center + justify-content: space-between + position: relative + padding-bottom: 5px + margin-bottom: 5px + border-bottom: 1px solid #bbb + + @media screen and (max-width: 400px) + flex-direction: column + align-items: start + padding-bottom: 15px + + .description + padding: 8px 8px 8px 0px + +// + .checkbox-row + .pill + display: flex + align-items: center + user-select: none + + .fake-checkbox + -webkit-appearance: none + background-color: white + width: 16px + height: 16px + padding: 0px + border: 1px solid #666 + border-radius: 3px + margin-left: 8px + position: relative + outline: none + + .checkbox + display: none + + &:checked + .pill .fake-checkbox + background: center center / contain url(/static/img/tick.svg) + + &:disabled + .pill + @include disabled + + .fake-checkbox + @include disabled + + &.checkbox:not(:disabled) + .pill + @include acts-like-button + cursor: pointer diff --git a/sass/includes/settings-page.sass b/sass/includes/settings-page.sass new file mode 100644 index 0000000..d7547bf --- /dev/null +++ b/sass/includes/settings-page.sass @@ -0,0 +1,11 @@ +.settings-page + padding: 40px 20px 20px + max-width: 600px + margin: 0 auto + +.save-settings + margin-top: 24px + + .border-look + font-size: 22px + padding: 7px 16px 8px diff --git a/sass/includes/subscriptions-page.sass b/sass/includes/subscriptions-page.sass index 13b5c8d..40d10a9 100644 --- a/sass/includes/subscriptions-page.sass +++ b/sass/includes/subscriptions-page.sass @@ -1,3 +1,4 @@ +@use "colors.sass" as c @use "video-list-item.sass" as * .subscriptions-page @@ -7,3 +8,28 @@ .subscriptions-video @include subscriptions-video + +.no-subscriptions + text-align: center + +.channels-details + margin-bottom: 24px + +.channels-list + display: grid + grid-gap: 8px + +.channel-item + display: flex + align-items: center + text-decoration: none + + .thumbnail + width: 40px + height: 40px + border-radius: 50% + margin-right: 8px + + .name + font-size: 22px + color: c.$fg-main diff --git a/sass/includes/video-page.sass b/sass/includes/video-page.sass index 51875d6..fd552eb 100644 --- a/sass/includes/video-page.sass +++ b/sass/includes/video-page.sass @@ -74,3 +74,8 @@ .related-video @include video-list-item + +.video-error-page + padding: 40px 20px 20px + margin: 0 auto + max-width: 600px diff --git a/sass/main.sass b/sass/main.sass index 1b0cf36..2eceffe 100644 --- a/sass/main.sass +++ b/sass/main.sass @@ -6,13 +6,15 @@ @use "includes/home-page.sass" @use "includes/channel-page.sass" @use "includes/subscriptions-page.sass" +@use "includes/settings-page.sass" +@use "includes/forms.sass" @font-face font-family: "Bariol" src: url(/static/fonts/bariol.woff?statichash=1) @mixin button-base - appearance: none + -webkit-appearance: none -moz-appearance: none color: c.$fg-bright border: none @@ -23,12 +25,15 @@ line-height: 1.25 @at-root #{selector.unify(&, "select")} - padding: 7px 27px 7px 8px + padding: 8px 27px 8px 8px background: url(/static/images/arrow-down-wide.svg) right 53% no-repeat c.$bg-accent-x @at-root #{selector.unify(&, "a")} padding: 7px 8px + @at-root #{selector.unify(&, "button")} + cursor: pointer + .button-icon position: relative top: 3px @@ -102,6 +107,9 @@ border: 1px solid c.$edge-grey margin: 0px -.subscriptions-link:hover, .subscriptions-link:focus - .subscriptions-icon +.icon-link:hover, .icon-link:focus + .icon filter: brightness(2) + +.button-container + display: flex diff --git a/server.js b/server.js index 9721bd1..b3c984d 100644 --- a/server.js +++ b/server.js @@ -26,4 +26,6 @@ const {setInstance} = require("pinski/plugins") server.addAPIDir("api") server.startServer() + + require("./background/feed-update") })()