1
0
mirror of https://git.sr.ht/~cadence/cloudtube synced 2024-12-22 13:07:00 +00:00

Settings page and instance selection

This commit is contained in:
Cadence Ember 2020-09-01 01:22:16 +12:00
parent 59a7489545
commit c573a5ac3e
No known key found for this signature in database
GPG Key ID: 128B99B1B74A6412
22 changed files with 587 additions and 71 deletions

View File

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

67
api/settings.js Normal file
View File

@ -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()
}
}
]

View File

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

View File

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

View File

@ -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()
}
]

View File

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

View File

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

View File

@ -0,0 +1,2 @@
const db = require("../api/utils/db")

View File

@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 6.6145832 6.6145837"
version="1.1"
id="svg27"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
sodipodi:docname="settings.svg">
<defs
id="defs21">
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath54">
<rect
style="opacity:1;fill:#c4c4c4;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.0583334;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"
id="rect56"
width="5.7141514"
height="5.8818746"
x="-344.6713"
y="-23.818602" />
</clipPath>
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="11.313708"
inkscape:cx="30.493294"
inkscape:cy="11.333495"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1440"
inkscape:window-height="877"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:snap-global="true"
inkscape:snap-center="true"
inkscape:snap-bbox="true"
inkscape:snap-bbox-midpoints="false"
showguides="false">
<inkscape:grid
type="xygrid"
id="grid40"
originx="-46.744516"
originy="-203.04119" />
</sodipodi:namedview>
<metadata
id="metadata24">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(344.08287,33.230171)">
<path
id="path100"
style="opacity:1;fill:#c4c4c4;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.0583334;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"
d="m -341.44225,-33.230169 c 0,0 -0.0312,0.939474 -0.42432,1.214551 -0.39309,0.275081 -1.33164,-0.320927 -1.33164,-0.320927 l -0.77299,1.338204 c -4e-5,-2.6e-5 -0.003,-0.0017 -0.003,-0.0017 l 0.003,0.0023 c 0.0111,0.0056 0.79687,0.498135 0.83826,0.973489 0.0416,0.477954 -0.94437,0.992482 -0.94437,0.992482 l 0.77131,1.339876 c 0,0 0.82948,-0.442244 1.26426,-0.239355 0.43479,0.202871 0.38726,1.313379 0.38726,1.313379 l 1.54597,0.0023 c 0,0 0.0312,-0.939459 0.42429,-1.21454 0.39309,-0.27508 1.33168,0.320916 1.33168,0.320916 l 0.77468,-1.337642 c 0,0 -0.79776,-0.496643 -0.8394,-0.974608 -0.0416,-0.477957 0.94383,-0.992483 0.94383,-0.992483 l -0.77131,-1.339874 c 0,0 -0.82893,0.442849 -1.26369,0.239971 -0.43478,-0.202878 -0.38784,-1.313998 -0.38784,-1.313998 z m 0.68755,2.250259 c 0.61082,-1.69e-4 1.10602,0.495039 1.10586,1.105858 -1.4e-4,0.610603 -0.49526,1.105472 -1.10586,1.105302 -0.61038,-1.38e-4 -1.10517,-0.494918 -1.1053,-1.105302 -1.7e-4,-0.6106 0.49469,-1.105718 1.1053,-1.105858 z"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccccccccccccccccccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

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

View File

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

View File

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

46
pug/settings.pug Normal file
View File

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

View File

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

View File

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

View File

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

81
sass/includes/forms.sass Normal file
View File

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

View File

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

View File

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

View File

@ -74,3 +74,8 @@
.related-video
@include video-list-item
.video-error-page
padding: 40px 20px 20px
margin: 0 auto
max-width: 600px

View File

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

View File

@ -26,4 +26,6 @@ const {setInstance} = require("pinski/plugins")
server.addAPIDir("api")
server.startServer()
require("./background/feed-update")
})()