update linters & add stylelint (#3023)

* update linters, add stylelint, switch from sass to scss

* remove unused babel-eslint module

* fix spacing in scss files

* dont use npm in script calls

* dont error for `:deep` selector in css
This commit is contained in:
ChunkyProgrammer 2023-01-03 13:19:41 -05:00 committed by GitHub
parent bc44e27469
commit 43a25f8738
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
81 changed files with 2309 additions and 1386 deletions

View File

@ -11,13 +11,24 @@ module.exports = {
// https://eslint.org/docs/user-guide/configuring#specifying-parser
parser: 'vue-eslint-parser',
// https://vuejs.github.io/eslint-plugin-vue/user-guide/#faq
// https://eslint.vuejs.org/user-guide/#faq
parserOptions: {
parser: 'babel-eslint',
ecmaVersion: 2018,
parser: '@babel/eslint-parser',
ecmaVersion: 2022,
sourceType: 'module'
},
overrides: [
{
files: ['*.json'],
parser: 'jsonc-eslint-parser',
rules: {
'no-tabs': 'off',
'comma-spacing': 'off'
}
}
],
// https://eslint.org/docs/user-guide/configuring#extending-configuration-files
// order matters: from least important to most important in terms of overriding
// Prettier + Vue: https://medium.com/@gogl.alex/how-to-properly-set-up-eslint-with-prettier-for-vue-or-nuxt-in-vscode-e42532099a9c
@ -25,12 +36,13 @@ module.exports = {
'prettier',
'eslint:recommended',
'plugin:vue/recommended',
'standard'
'standard',
'plugin:jsonc/recommended-with-json',
// 'plugin:vuejs-accessibility/recommended' // uncomment once issues are fixed
],
// https://eslint.org/docs/user-guide/configuring#configuring-plugins
plugins: ['vue', 'vuejs-accessibility'],
plugins: ['vue', 'vuejs-accessibility', 'n', 'unicorn'],
rules: {
'space-before-function-paren': 'off',
@ -39,6 +51,7 @@ module.exports = {
'no-console': ['error', { allow: ['warn', 'error'] }],
'no-unused-vars': 'warn',
'no-undef': 'warn',
'object-shorthand': 'off',
'vue/no-template-key': 'warn',
'vue/no-useless-template-attributes': 'off',
'vue/multi-word-component-names': 'off',
@ -47,6 +60,13 @@ module.exports = {
required: {
some: ['nesting', 'id']
}
}]
}],
'n/no-callback-literal': 'warn',
'n/no-path-concat': 'warn',
'unicorn/better-regex': 'error',
'unicorn/no-array-push-push': 'error',
'unicorn/prefer-keyboard-event-key': 'error',
'unicorn/prefer-regexp-test': 'error',
'unicorn/prefer-string-replace-all': 'error'
}
}

7
.stylelintignore Normal file
View File

@ -0,0 +1,7 @@
src/data/
src/datastores/
src/main/
src/renderer/videoJS.css
dist/
static/
node_modules/

34
.stylelintrc.json Normal file
View File

@ -0,0 +1,34 @@
{
"plugins": ["stylelint-high-performance-animation", "@double-great/stylelint-a11y"],
"extends": ["stylelint-config-standard", "stylelint-config-sass-guidelines"],
"overrides": [
{
"files": ["**/*.scss"],
"customSyntax": "postcss-scss",
"rules": {
"max-nesting-depth": null,
"selector-max-compound-selectors": null
}
},
{
"files": ["**/*.css"],
"rules": {
"a11y/media-prefers-reduced-motion": true,
"a11y/no-outline-none": true,
"a11y/selector-pseudo-class-focus": true,
"a11y/font-size-is-readable": true
}
}
],
"rules": {
"selector-class-pattern": null,
"selector-id-pattern": null,
"plugin/no-low-performance-animation-properties": true,
"selector-pseudo-class-no-unknown": [
true,
{
"ignorePseudoClasses": ["deep"]
}
]
}
}

View File

@ -48,7 +48,7 @@ const config = {
loader: 'vue-loader',
},
{
test: /\.s(c|a)ss$/,
test: /\.scss$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
@ -62,11 +62,7 @@ const config = {
{
loader: 'sass-loader',
options: {
// eslint-disable-next-line
implementation: require('sass'),
sassOptions: {
indentedSyntax: true
}
implementation: require('sass')
}
},
],

View File

@ -39,7 +39,7 @@ const config = {
loader: 'vue-loader'
},
{
test: /\.s(c|a)ss$/,
test: /\.scss$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
@ -53,11 +53,7 @@ const config = {
{
loader: 'sass-loader',
options: {
// eslint-disable-next-line
implementation: require('sass'),
sassOptions: {
indentedSyntax: true
}
implementation: require('sass')
}
},
],

View File

@ -1,4 +1,3 @@
# Refer for explanation to following link:
# https://github.com/evilmartians/lefthook/blob/master/docs/full_guide.md
pre-commit:
@ -12,8 +11,6 @@ pre-commit:
skip:
- rebase
# EXAMPLE USAGE
#
# pre-push:

View File

@ -31,8 +31,15 @@
"dev": "run-s rebuild:electron dev-runner",
"dev:web": "node _scripts/dev-runner.js --web",
"dev-runner": "node _scripts/dev-runner.js",
"lint-all": "run-p lint lint-json lint-style",
"lint-fix": "eslint --fix --ext .js,.vue ./",
"lint": "eslint --ext .js,.vue ./",
"lint-json": "eslint --ext .json ./",
"lint-style": "run-p lint-style:scss lint-style:css",
"lint-style:scss": "stylelint \"**/*.scss\"",
"lint-style:css": "stylelint \"**/*.css\"",
"lint-style-fix:scss": "stylelint --fix \"**/*.scss\"",
"lint-style-fix:css": "stylelint --fix \"**/*.css\"",
"pack": "run-p pack:main pack:renderer",
"pack:main": "webpack --mode=production --node-env=production --config _scripts/webpack.main.config.js",
"pack:renderer": "webpack --mode=production --node-env=production --config _scripts/webpack.renderer.config.js",
@ -81,23 +88,25 @@
},
"devDependencies": {
"@babel/core": "^7.20.7",
"@babel/eslint-parser": "^7.19.1",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/preset-env": "^7.20.2",
"babel-eslint": "^10.1.0",
"@double-great/stylelint-a11y": "^2.0.2",
"babel-loader": "^9.1.0",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.7.3",
"css-minimizer-webpack-plugin": "^4.2.2",
"electron": "^22.0.0",
"electron-builder": "^23.6.0",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.3.0",
"eslint-config-standard": "^16.0.3",
"eslint-plugin-import": "^2.24.2",
"eslint-plugin-node": "^11.1.0",
"eslint": "^8.31.0",
"eslint-config-prettier": "^8.6.0",
"eslint-config-standard": "^17.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsonc": "^2.5.0",
"eslint-plugin-n": "^15.6.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-promise": "^5.1.0",
"eslint-plugin-standard": "^5.0.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-unicorn": "^45.0.2",
"eslint-plugin-vue": "^9.8.0",
"eslint-plugin-vuejs-accessibility": "^2.0.0",
"html-webpack-plugin": "^5.3.2",
@ -106,10 +115,16 @@
"lefthook": "^1.2.4",
"mini-css-extract-plugin": "^2.7.2",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.20",
"postcss-scss": "^4.0.6",
"prettier": "^2.8.1",
"rimraf": "^3.0.2",
"sass": "^1.57.1",
"sass-loader": "^13.2.0",
"stylelint": "^14.16.1",
"stylelint-config-sass-guidelines": "^9.0.1",
"stylelint-config-standard": "^29.0.0",
"stylelint-high-performance-animation": "^1.7.0",
"tree-kill": "1.2.2",
"vue-devtools": "^5.1.4",
"vue-eslint-parser": "^9.1.0",

View File

@ -51,7 +51,7 @@ export class ImageCache {
* @returns a timestamp in seconds
*/
export function extractExpiryTimestamp(headers) {
const maxAgeRegex = /max-age=([0-9]+)/
const maxAgeRegex = /max-age=(\d+)/
const cacheControl = headers['cache-control']
if (cacheControl && maxAgeRegex.test(cacheControl)) {

View File

@ -283,7 +283,7 @@ function runApp() {
session.defaultSession.webRequest.onBeforeSendHeaders(innertubeRequestFilter, ({ requestHeaders }, callback) => {
requestHeaders.referer = 'https://www.youtube.com'
// eslint-disable-next-line node/no-callback-literal
// eslint-disable-next-line n/no-callback-literal
callback({ requestHeaders })
})
@ -298,7 +298,7 @@ function runApp() {
if (imageCache.has(url)) {
const cached = imageCache.get(url)
// eslint-disable-next-line node/no-callback-literal
// eslint-disable-next-line n/no-callback-literal
callback({
mimeType: cached.mimeType,
data: cached.data
@ -336,7 +336,7 @@ function runApp() {
imageCache.add(url, mimeType, data, expiryTimestamp)
// eslint-disable-next-line node/no-callback-literal
// eslint-disable-next-line n/no-callback-literal
callback({
mimeType,
data: data
@ -364,7 +364,7 @@ function runApp() {
return value
})
// eslint-disable-next-line node/no-callback-literal
// eslint-disable-next-line n/no-callback-literal
callback({
statusCode: response.statusCode ?? 400,
mimeType: 'application/json',
@ -385,12 +385,12 @@ function runApp() {
// the requests made by the imagecache:// handler to fetch the image,
// are allowed through, as their resourceType is 'other'
if (details.resourceType === 'image') {
// eslint-disable-next-line node/no-callback-literal
// eslint-disable-next-line n/no-callback-literal
callback({
redirectURL: `imagecache://${encodeURIComponent(details.url)}`
})
} else {
// eslint-disable-next-line node/no-callback-literal
// eslint-disable-next-line n/no-callback-literal
callback({})
}
})

View File

@ -228,7 +228,7 @@ export default Vue.extend({
let count = 0
const ytsubs = youtubeSubscriptions.slice(1).map(yt => {
const splitCSVRegex = /(?:,|\n|^)("(?:(?:"")*[^"]*)*"|[^",\n]*|(?:\n|$))/g
const splitCSVRegex = /(?:,|\n|^)("(?:(?:"")*[^"]*)*"|[^\n",]*|(?:\n|$))/g
return [...yt.matchAll(splitCSVRegex)].map(s => {
let newVal = s[1]
if (newVal.startsWith('"')) {
@ -623,11 +623,11 @@ export default Vue.extend({
this.profileList[0].subscriptions.forEach((channel) => {
const escapedName = channel.name
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll('\'', '&apos;')
const channelOpmlString = `<outline text="${escapedName}" title="${escapedName}" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=${channel.id}"/>`
opmlData += channelOpmlString

View File

@ -1,2 +0,0 @@
.folderDisplay
width: 50vh

View File

@ -0,0 +1,3 @@
.folderDisplay {
width: 50vh;
}

View File

@ -44,4 +44,4 @@
</template>
<script src="./download-settings.js" />
<style scoped lang="sass" src="./download-settings.sass" />
<style scoped lang="scss" src="./download-settings.scss" />

View File

@ -1,14 +0,0 @@
.ft-age-restricted
color: var(--primary-text-color)
h2
width: 100%
text-align: center
background-color: var(--card-bg-color)
padding: 10px 0
.frown
width: 100%
text-align: center
background-color: var(--card-bg-color)
font-size: 10em
padding: 20px 0
height: 100%

View File

@ -0,0 +1,19 @@
.ft-age-restricted {
color: var(primary-text-color);
h2 {
background-color: var(card-bg-color);
padding: 10px 0;
text-align: center;
width: 100%;
}
.frown {
background-color: var(card-bg-color);
font-size: 10em;
height: 100%;
padding: 20px 0;
text-align: center;
width: 100%;
}
}

View File

@ -12,4 +12,4 @@
</template>
<script src="./ft-age-restricted.js" />
<style scoped lang="sass" src="./ft-age-restricted.sass" />
<style scoped lang="scss" src="./ft-age-restricted.scss" />

View File

@ -1,10 +0,0 @@
.ft-auto-grid
&.grid
display: grid
grid-template-columns: repeat(auto-fill, minmax(262px, 1fr) )
justify-content: space-evenly
grid-gap: 8px
&.list
display: grid
grid-gap: 4px

View File

@ -0,0 +1,13 @@
.ft-auto-grid {
&.grid {
display: grid;
grid-gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(262px, 1fr));
justify-content: space-evenly;
}
&.list {
display: grid;
grid-gap: 4px;
}
}

View File

@ -11,4 +11,4 @@
</template>
<script src="./ft-auto-grid.js" />
<style scoped lang="sass" src="./ft-auto-grid.sass" />
<style scoped lang="scss" src="./ft-auto-grid.scss" />

View File

@ -1,103 +0,0 @@
.ftIconButton
display: flex
flex-flow: row wrap
justify-content: space-evenly
position: relative
user-select: none
.iconButton
width: 1em
height: 1em
border-radius: 50%
cursor: pointer
transition: background 0.15s ease-out
&.shadow
box-shadow: 0 1px 2px rgba(0,0,0,.5)
&.base
background-color: var(--card-bg-color)
color: var(--primary-text-color)
&:hover
background-color: var(--side-nav-hover-color)
&:active
background-color: var(--side-nav-active-color)
&.base-no-default
&:hover
background-color: var(--side-nav-hover-color)
&:active
background-color: var(--side-nav-active-color)
&.primary
background-color: var(--primary-color)
color: var(--text-with-main-color)
&:hover
background-color: var(--primary-color-hover)
&:active
background-color: var(--primary-color-active)
&.secondary
background-color: var(--accent-color)
color: var(--text-with-accent-color)
&:hover
background-color: var(--accent-color-hover)
&:active
background-color: var(--accent-color-active)
&.favorite
color: var(--favorite-icon-color)
.iconDropdown
display: inline
position: absolute
text-align: center
list-style-type: none
z-index: 3
margin-top: 45px
font-size: 12px
box-shadow: 0 1px 2px rgba(0,0,0,.5)
background-color: var(--side-nav-color)
color: var(--secondary-text-color)
user-select: none
&.left
right: calc(50% - 10px)
&.right
left: calc(50% - 10px)
.list
margin: 0
padding: 0
list-style-type: none
.listItem
padding: 8px 10px
margin: 0
white-space: nowrap
cursor: pointer
transition: background 0.2s ease-out
&:hover, &:focus
background-color: var(--side-nav-hover-color)
transition: background 0.2s ease-in
&:active
background-color: var(--side-nav-active-color)
transition: background 0.1s ease-in
.listItemDivider
width: 95%
margin: 1px auto
border-top: 1px solid var(--tertiary-text-color)
// Too "visible" with current color
opacity: 50%

View File

@ -0,0 +1,127 @@
.ftIconButton {
display: flex;
flex-flow: row wrap;
justify-content: space-evenly;
position: relative;
user-select: none;
}
.iconButton {
border-radius: 50%;
cursor: pointer;
height: 1em;
transition: background 0.15s ease-out;
width: 1em;
&.shadow {
box-shadow: 0 1px 2px rgb(0 0 0 / 50%);
}
&.base {
background-color: var(card-bg-color);
color: var(primary-text-color);
&:hover {
background-color: var(side-nav-hover-color);
}
&:active {
background-color: var(side-nav-active-color);
}
}
&.base-no-default {
&:hover {
background-color: var(side-nav-hover-color);
}
&:active {
background-color: var(side-nav-active-color);
}
}
&.primary {
background-color: var(primary-color);
color: var(text-with-main-color);
&:hover {
background-color: var(primary-color-hover);
}
&:active {
background-color: var(primary-color-active);
}
}
&.secondary {
background-color: var(accent-color);
color: var(text-with-accent-color);
&:hover {
background-color: var(accent-color-hover);
}
&:active {
background-color: var(accent-color-active);
}
}
&.favorite {
color: var(favorite-icon-color);
}
}
.iconDropdown {
background-color: var(side-nav-color);
box-shadow: 0 1px 2px rgb(0 0 0 / 50%);
color: var(secondary-text-color);
display: inline;
font-size: 12px;
list-style-type: none;
margin-top: 45px;
position: absolute;
text-align: center;
user-select: none;
z-index: 3;
&.left {
right: calc(50% - 10px);
}
&.right {
left: calc(50% - 10px);
}
.list {
list-style-type: none;
margin: 0;
padding: 0;
}
.listItem {
cursor: pointer;
margin: 0;
padding: 8px 10px;
transition: background 0.2s ease-out;
white-space: nowrap;
&:hover,
&:focus {
background-color: var(side-nav-hover-color);
transition: background 0.2s ease-in;
}
&:active {
background-color: var(side-nav-active-color);
transition: background 0.1s ease-in;
}
}
.listItemDivider {
border-top: 1px solid var(tertiary-text-color);
margin: 1px auto;
// Too "visible" with current color
opacity: 0.5;
width: 95%;
}
}

View File

@ -61,4 +61,4 @@
</template>
<script src="./ft-icon-button.js" />
<style scoped lang="sass" src="./ft-icon-button.sass" />
<style scoped lang="scss" src="./ft-icon-button.scss" />

View File

@ -1 +0,0 @@
@use "../../sass-partials/_ft-list-item"

View File

@ -0,0 +1 @@
@use '../../scss-partials/_ft-list-item';

View File

@ -48,4 +48,4 @@
</template>
<script src="./ft-list-channel.js" />
<style scoped lang="sass" src="./ft-list-channel.sass" />
<style scoped lang="scss" src="./ft-list-channel.scss" />

View File

@ -1 +0,0 @@
@use "../../sass-partials/_ft-list-item"

View File

@ -0,0 +1 @@
@use '../../scss-partials/_ft-list-item';

View File

@ -52,4 +52,4 @@
</template>
<script src="./ft-list-playlist.js" />
<style scoped lang="sass" src="./ft-list-playlist.sass" />
<style scoped lang="scss" src="./ft-list-playlist.scss" />

View File

@ -1,4 +0,0 @@
@use "../../sass-partials/_ft-list-item"
.thumbnailLink:hover
outline: 3px solid var(--side-nav-hover-color)

View File

@ -0,0 +1,5 @@
@use '../../scss-partials/_ft-list-item';
.thumbnailLink:hover {
outline: 3px solid var(side-nav-hover-color);
}

View File

@ -120,4 +120,4 @@
</template>
<script src="./ft-list-video.js" />
<style scoped src="./ft-list-video.sass" lang="sass" />
<style scoped src="./ft-list-video.scss" lang="scss" />

View File

@ -1,82 +0,0 @@
.settingsSection
background-color: var(--card-bg-color)
width: 85%
margin: 0 auto
@media only screen and (max-width: 800px)
width: 100%
&[open]
padding-bottom: 15px
> div
width: 100%
padding: 0px 20px
box-sizing: border-box
> div:not(:last-child):not(.ft-flex-box)
@media only screen and (max-width: 800px)
margin-bottom: 20px
.sectionLine
width: 100%
height: 2px
border: 0
margin-top: -1px
background-color: var(--primary-color)
.sectionHeader
display: block
cursor: pointer
padding: 1px
.sectionTitle
margin-left: 2%
:deep(.switchGrid)
display: grid
grid-template-columns: auto auto
justify-content: space-evenly
align-items: center
@media only screen and (max-width: 680px)
grid-template-columns: auto
:deep(.switchColumnGrid)
@extend :deep(.switchGrid)
align-items: start
:deep(.switchColumn)
display: flex
flex-direction: column
justify-items: start
:deep(.center)
text-align: center
@media only screen and (max-width: 460px)
:deep(.settingsFlexStart460px)
justify-content: flex-start
@media only screen and (max-width: 500px)
:deep(.settingsFlexStart500px)
justify-content: flex-start
@media only screen and (max-width: 680px)
.settingsSection
> div
:deep(.text.bottom)
left: -85px
:deep(.switch-ctn.containsTooltip)
left: -10px
margin-right: 5px
padding: 0px 10px 0px 10px
:not(.select, .selectLabel)
> :deep(.tooltip)
display: inline-block
position: absolute
right: -25px
top: 12px
.settingsFlexStart460px :deep(.tooltip)
right: 0px
top: -2px
:deep(.switch-ctn)
margin: 10px 7px

View File

@ -0,0 +1,116 @@
.settingsSection {
background-color: var(card-bg-color);
margin: 0 auto;
width: 85%;
@media only screen and (max-width: 800px) {
width: 100%;
}
&[open] {
padding-bottom: 15px;
}
> div {
box-sizing: border-box;
padding: 0 20px;
width: 100%;
}
> div:not(:last-child, .ft-flex-box) {
@media only screen and (max-width: 800px) {
margin-bottom: 20px;
}
}
}
.sectionLine {
background-color: var(primary-color);
border: 0;
height: 2px;
margin-top: -1px;
width: 100%;
}
.sectionHeader {
cursor: pointer;
display: block;
padding: 1px;
}
.sectionTitle {
margin-left: 2%;
}
:deep(.switchGrid) {
align-items: center;
display: grid;
grid-template-columns: auto auto;
justify-content: space-evenly;
@media only screen and (max-width: 680px) {
grid-template-columns: auto;
}
}
:deep(.switchColumnGrid) {
@extend :deep(.switchGrid);
align-items: start;
}
:deep(.switchColumn) {
display: flex;
flex-direction: column;
justify-items: start;
}
:deep(.center) {
text-align: center;
}
@media only screen and (max-width: 460px) {
:deep(.settingsFlexStart460px) {
justify-content: flex-start;
}
}
@media only screen and (max-width: 500px) {
:deep(.settingsFlexStart500px) {
justify-content: flex-start;
}
}
@media only screen and (max-width: 680px) {
.settingsSection {
> div {
:deep(.text.bottom) {
left: -85px;
}
}
:deep(.switch-ctn.containsTooltip) {
left: -10px;
margin-right: 5px;
padding: 0 10px;
}
:not(.select, .selectLabel) {
> :deep(.tooltip) {
display: inline-block;
position: absolute;
right: -25px;
top: 12px;
}
}
.settingsFlexStart460px :deep(.tooltip) {
right: 0;
top: -2px;
}
:deep(.switch-ctn) {
margin: 10px 7px;
}
}
}

View File

@ -11,4 +11,4 @@
</template>
<script src="./ft-settings-section.js" />
<style scoped src="./ft-settings-section.sass" lang="sass" />
<style scoped src="./ft-settings-section.scss" lang="scss" />

View File

@ -1,64 +0,0 @@
.shareLinks
display: grid
grid-template-rows: auto auto
grid-auto-flow: column
padding: 12px
width: max-content
.header
font-size: 18px
font-weight: bold
margin: 4px 0px 8px
color: var(--primary-text-color)
.buttons
display: flex
flex-direction: column
.action
padding: 6px
.divider
grid-row: span 3
margin: 0px 12px
width: 1px
background: var(--tertiary-text-color)
.youtubeLogo
height: 18px
width: auto
@at-root
.dark &, .system[data-system-theme*='dark'] &
filter: brightness(0.868)
.black &
filter: brightness(0.933)
/* no changes for the dracula theme */
.light &, .system[data-system-theme*='light'] &
filter: invert(0.87)
.invidious
display: flex
justify-content: center
letter-spacing: -0.4px
.invidiousLogo
display: inline-block
width: 20px
height: 20px
background-size: cover
margin-right: 2px
@at-root
.dark &,
.black &,
.dracula &,
.CatppuccinMocha &,
.system[data-system-theme*='dark'] &
background-image: url(../../assets/img/invidious-logo-dark.svg)
.light &, .system[data-system-theme*='light'] &
background-image: url(../../assets/img/invidious-logo-light.svg)

View File

@ -0,0 +1,82 @@
.shareLinks {
display: grid;
grid-auto-flow: column;
grid-template-rows: auto auto;
padding: 12px;
width: max-content;
.header {
color: var(primary-text-color);
font-size: 18px;
font-weight: bold;
margin: 4px 0 8px;
}
.buttons {
display: flex;
flex-direction: column;
.action {
padding: 6px;
}
}
.divider {
background: var(tertiary-text-color);
grid-row: span 3;
margin: 0 12px;
width: 1px;
}
.youtubeLogo {
height: 18px;
width: auto;
@at-root {
.dark &,
.system[data-system-theme*='dark'] & {
filter: brightness(0.868);
}
.black & {
filter: brightness(0.933);
}
/* no changes for the dracula theme */
.light &,
.system[data-system-theme*='light'] & {
filter: invert(0.87);
}
}
}
.invidious {
display: flex;
justify-content: center;
letter-spacing: -0.4px;
.invidiousLogo {
background-size: cover;
display: inline-block;
height: 20px;
margin-right: 2px;
width: 20px;
@at-root {
.dark &,
.black &,
.dracula &,
.CatppuccinMocha &,
.system[data-system-theme*='dark'] & {
background-image: url(../../assets/img/invidious-logo-dark.svg);
}
.light &,
.system[data-system-theme*='light'] & {
background-image: url(../../assets/img/invidious-logo-light.svg);
}
}
}
}
}

View File

@ -119,4 +119,4 @@
</template>
<script src="./ft-share-button.js" />
<style scoped lang="sass" src="./ft-share-button.sass" />
<style scoped lang="scss" src="./ft-share-button.scss" />

View File

@ -34,7 +34,7 @@ export default Vue.extend({
colorNames: function () {
return this.colorValues.map(colorVal => {
// add spaces before capital letters
const colorName = colorVal.replace(/([A-Z])/g, ' $1').trim()
const colorName = colorVal.replaceAll(/([A-Z])/g, ' $1').trim()
return this.$t(`Settings.Theme Settings.Main Color Theme.${colorName}`)
})
},

View File

@ -1,7 +0,0 @@
.sponsorBlockCategory
margin-top: 30px
padding: 0 10px
@media only screen and (max-width: 680px)
width: 100%
.sponsorTitle
font-size: x-large

View File

@ -0,0 +1,12 @@
.sponsorBlockCategory {
margin-top: 30px;
padding: 0 10px;
@media only screen and (max-width: 680px) {
width: 100%;
}
.sponsorTitle {
font-size: x-large;
}
}

View File

@ -20,4 +20,4 @@
</div>
</template>
<script src="./ft-sponsor-block-category.js" />
<style scoped lang="sass" src="./ft-sponsor-block-category.sass" />
<style scoped lang="scss" src="./ft-sponsor-block-category.scss" />

View File

@ -20,7 +20,7 @@ export default Vue.extend({
}
},
detectTimestamps: function (input) {
return input.replace(/(\d+(:\d+)+)/g, '<a href="#" onclick="this.dispatchEvent(new CustomEvent(\'timestamp-clicked\',{bubbles:true, detail:\'$1\'}))">$1</a>')
return input.replaceAll(/(\d+(:\d+)+)/g, '<a href="#" onclick="this.dispatchEvent(new CustomEvent(\'timestamp-clicked\',{bubbles:true, detail:\'$1\'}))">$1</a>')
}
}
})

View File

@ -1,81 +0,0 @@
/* Thanks to Guus Lieben for the Material Design Switch */
.switch-ctn
margin: 20px 16px
position: relative
&.compact
margin: 0
.disabled
.switch-label
cursor: not-allowed
.switch-label-text
opacity: 0.4
.switch-input
-moz-appearance: none
-webkit-appearance: none
appearance: none
height: 20px
left: -3px
position: absolute
top: calc(50% - 3px)
-ms-transform: translate(0, (-50%))
-webkit-transform: translate(0, -50%)
transform: translate(0, -50%)
width: 34px
.switch-label
position: relative
display: inline-block
cursor: pointer
font-weight: 500
text-align: left
padding: 12px 0 12px 44px
&:before, &:after
content: ""
position: absolute
margin: 0
outline: 0
top: 50%
-ms-transform: translate(0, -50%)
-webkit-transform: translate(0, -50%)
transform: translate(0, -50%)
-webkit-transition: all 0.3s ease
transition: all 0.3s ease
&:before
left: 1px
width: 34px
height: 14px
background-color: #9E9E9E
border-radius: 8px
.switch-input:checked + &
background-color: var(--accent-color-light)
.switch-input:disabled + &
background-color: #9E9E9E
&:after
left: 0
width: 20px
height: 20px
background-color: #FAFAFA
border-radius: 50%
box-shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.14), 0 2px 2px 0 rgba(0, 0, 0, 0.098), 0 1px 5px 0 rgba(0, 0, 0, 0.084)
.switch-input:checked + &
background-color: var(--accent-color)
-ms-transform: translate(80%, -50%)
-webkit-transform: translate(80%, -50%)
transform: translate(80%, -50%)
.switch-input:disabled + &
background-color: #BDBDBD
@media (max-width: 680px)
max-width: 250px

View File

@ -0,0 +1,88 @@
/* Thanks to Guus Lieben for the Material Design Switch */
.switch-ctn {
margin: 20px 16px;
position: relative;
&.compact {
margin: 0;
}
}
.switch-input {
appearance: none;
height: 20px;
left: -3px;
position: absolute;
top: calc(50% - 3px);
transform: translate(0, -50%);
width: 34px;
}
.switch-label {
cursor: pointer;
display: inline-block;
font-weight: 500;
padding: 12px 0 12px 44px;
position: relative;
text-align: left;
&::before,
&::after {
content: '';
margin: 0;
outline: 0;
position: absolute;
top: 50%;
transform: translate(0, -50%);
transition: all 0.3s ease;
}
&::before {
background-color: #9e9e9e;
border-radius: 8px;
height: 14px;
left: 1px;
width: 34px;
.switch-input:checked + & {
background-color: var(accent-color-light);
}
.switch-input:disabled + & {
background-color: #9e9e9e;
}
}
&::after {
background-color: #fafafa;
border-radius: 50%;
box-shadow: 0 3px 1px -2px rgb(0 0 0 / 14%), 0 2px 2px 0 rgb(0 0 0 / 9.8%), 0 1px 5px 0 rgb(0 0 0 / 8.4%);
height: 20px;
left: 0;
width: 20px;
.switch-input:checked + & {
background-color: var(accent-color);
transform: translate(80%, -50%);
}
.switch-input:disabled + & {
background-color: #bdbdbd;
}
}
@media (max-width: 680px) {
max-width: 250px;
}
}
.disabled {
.switch-label {
cursor: not-allowed;
}
.switch-label-text {
opacity: 0.4;
}
}

View File

@ -35,4 +35,4 @@
</template>
<script src="./ft-toggle-switch.js" />
<style scoped lang="sass" src="./ft-toggle-switch.sass" />
<style scoped lang="scss" src="./ft-toggle-switch.scss" />

View File

@ -1,8 +0,0 @@
.select
min-width: 240px
width: auto
// https://vue-loader.vuejs.org/guide/scoped-css.html#deep-selectors
.select:deep(.select-text)
min-width: 240px
width: auto

View File

@ -0,0 +1,9 @@
.select {
min-width: 240px;
width: auto;
}
// https://vue-loader.vuejs.org/guide/scoped-css.html#deep-selectors
.select:deep(.select-text) {
min-width: 240px;
width: auto;
}

View File

@ -141,4 +141,4 @@
</template>
<script src="./general-settings.js" />
<style scoped lang="sass" src="./general-settings.sass" />
<style scoped lang="scss" src="./general-settings.scss" />

View File

@ -1,12 +0,0 @@
.screenshotFolderContainer
width: 95%
margin: 0 auto
align-items: center
column-gap: 1rem
.screenshotFolderLabel, .screenshotFolderButton, .screenshotFilenamePatternTitle
flex-grow: 0
.screenshotFolderPath, .screenshotFilenamePatternInput, .screenshotFilenamePatternExample
flex-grow: 1
margin-top: 10px

View File

@ -0,0 +1,19 @@
.screenshotFolderContainer {
align-items: center;
column-gap: 1rem;
margin: 0 auto;
width: 95%;
.screenshotFolderLabel,
.screenshotFolderButton,
.screenshotFilenamePatternTitle {
flex-grow: 0;
}
.screenshotFolderPath,
.screenshotFilenamePatternInput,
.screenshotFilenamePatternExample {
flex-grow: 1;
margin-top: 10px;
}
}

View File

@ -246,4 +246,4 @@
</template>
<script src="./player-settings.js" />
<style scoped lang="sass" src="./player-settings.sass" />
<style scoped lang="scss" src="./player-settings.scss" />

View File

@ -1,45 +0,0 @@
.playListThumbnail
width: 100%
.playlistThumbnail img
width: 100%
cursor: pointer
@media only screen and (max-width: 800px)
display: none
.playlistStats
font-size: 15px
.playlistStats p
color: var(--secondary-text-color)
margin: 0
.playlistTitle
margin-bottom: 0.1em
.playlistDescription
max-height: 20vh
overflow-y: auto
white-space: break-spaces
@media only screen and (max-width: 500px)
max-height: 10vh
.playlistChannel
display: flex
align-items: center
gap: 8px
height: 40px
text-decoration: none
color: inherit
.channelThumbnail
width: 40px
float: left
border-radius: 200px 200px 200px 200px
-webkit-border-radius: 200px 200px 200px 200px
.channelName
margin: 0
font-size: 15px

View File

@ -0,0 +1,55 @@
.playListThumbnail {
width: 100%;
}
.playlistThumbnail img {
cursor: pointer;
width: 100%;
@media only screen and (max-width: 800px) {
display: none;
}
}
.playlistStats {
font-size: 15px;
}
.playlistStats p {
color: var(secondary-text-color);
margin: 0;
}
.playlistTitle {
margin-bottom: 0.1em;
}
.playlistDescription {
max-height: 20vh;
overflow-y: auto;
white-space: break-spaces;
@media only screen and (max-width: 500px) {
max-height: 10vh;
}
}
.playlistChannel {
align-items: center;
color: inherit;
display: flex;
gap: 8px;
height: 40px;
text-decoration: none;
}
.channelThumbnail {
border-radius: 200px;
float: left;
width: 40px;
}
.channelName {
font-size: 15px;
margin: 0;
}

View File

@ -66,4 +66,4 @@
</template>
<script src="./playlist-info.js" />
<style scoped lang="sass" src="./playlist-info.sass" />
<style scoped lang="scss" src="./playlist-info.scss" />

View File

@ -109,7 +109,7 @@ export default Vue.extend({
colorNames: function () {
return this.colorValues.map(colorVal => {
// add spaces before capital letters
const colorName = colorVal.replace(/([A-Z])/g, ' $1').trim()
const colorName = colorVal.replaceAll(/([A-Z])/g, ' $1').trim()
return this.$t(`Settings.Theme Settings.Main Color Theme.${colorName}`)
})
},

View File

@ -1,166 +0,0 @@
@mixin top-nav-is-colored
@at-root
.topNavBarColor &, .topNavBarColor#{&}
@content
.topNav
position: sticky
z-index: 4
left: 0
right: 0
top: 0
height: 60px
width: 100%
line-height: 60px
background-color: var(--card-bg-color)
-webkit-box-shadow: 0px 2px 1px 0px var(--primary-shadow-color)
display: flex
align-items: center
align-content: center
@media only screen and (min-width: 961px)
display: grid
grid-template-columns: 1fr 440px 1fr
@include top-nav-is-colored
background-color: var(--primary-color)
@media only screen and (max-width: 680px)
position: fixed
.menuIcon // the hamburger button
@media only screen and (max-width: 680px)
display: none
.navIcon // all icons in the top navigation
font-size: 20px
padding: 10px
cursor: pointer
color: var(--primary-text-color)
border-radius: 50%
transition: background 0.2s ease-out
width: 1em
height: 1em
&.fa-arrow-left, &.fa-arrow-right
color: gray
opacity: 0.5
pointer-events: none
user-select: none
@include top-nav-is-colored
color: var(--text-with-main-color)
&:hover
background-color: var(--primary-color-hover)
&:hover
background-color: var(--side-nav-hover-color)
transition: background 0.2s ease-in
&:active
background-color: var(--tertiary-text-color)
transition: background 0.2s ease-in
@include top-nav-is-colored
background-color: var(--primary-color-active)
.navFilterIcon // Filter icon
$effect-distance: 10px
margin-left: $effect-distance
&.filterChanged // When filter value changed from default
box-shadow: 0 0 $effect-distance var(--primary-color)
@include top-nav-is-colored
box-shadow: 0 0 $effect-distance var(--text-with-main-color)
.side // parts of the top nav either side of the search bar
display: flex
gap: 3px
margin: 0 6px
align-items: center
&.profiles
justify-content: flex-end
.navSearchIcon
@media only screen and (min-width: 681px)
display: none
.navNewWindowIcon
@media only screen and (max-width: 680px)
display: none
.logo // parts that make up the logo
display: flex
align-items: center
padding: 0px 25px 0px 10px
cursor: pointer
&:active
background-color: var(--tertiary-text-color)
transition: background 0.2s ease-in
@include top-nav-is-colored
background-color: var(--primary-color-active)
.logoIcon
background-image: var(--logo-icon)
background-repeat: no-repeat
background-position: right top
background-size: 25px
width: 25px
height: 25px
@include top-nav-is-colored
background-image: var(--logo-icon-bar-color)
.logoText
margin-left: 5px
position: relative
top: -3px
background-image: var(--logo-text)
background-repeat: no-repeat
background-position: right top
background-size: 100px
width: 100px
height: 40px
@media only screen and (max-width: 680px)
display: none
@include top-nav-is-colored
background-image: var(--logo-text-bar-color)
.middle // the middle part of the top nav which contains the search bar
max-width: 440px
flex: 1
.searchContainer
display: flex
align-items: center
@media only screen and (max-width: 680px)
position: fixed
left: 0
right: 0
top: 60px
background-color: var(--side-nav-color)
@include top-nav-is-colored
background-color: var(--primary-color-hover)
.searchInput
flex: 1
.searchFilters
position: absolute
left: 0
right: 0
margin: 10px 20px 20px 220px
transition: margin 150ms ease-in-out
@media only screen and (max-width: 680px)
left: 0
right: 0
margin: 95px 10px 0px

View File

@ -0,0 +1,210 @@
@mixin top-nav-is-colored {
@at-root {
.topNavBarColor &,
.topNavBarColor#{&} {
@content;
}
}
}
.topNav {
align-content: center;
align-items: center;
background-color: var(card-bg-color);
box-shadow: 0 2px 1px 0 var(primary-shadow-color);
display: flex;
height: 60px;
left: 0;
line-height: 60px;
position: sticky;
right: 0;
top: 0;
width: 100%;
z-index: 4;
@media only screen and (min-width: 961px) {
display: grid;
grid-template-columns: 1fr 440px 1fr;
}
@include top-nav-is-colored {
background-color: var(primary-color);
}
@media only screen and (max-width: 680px) {
position: fixed;
}
}
.menuIcon {
@media only screen and (max-width: 680px) {
display: none;
}
}
.navIcon {
border-radius: 50%;
color: var(primary-text-color);
cursor: pointer;
font-size: 20px;
height: 1em;
padding: 10px;
transition: background 0.2s ease-out;
width: 1em;
@include top-nav-is-colored {
color: var(text-with-main-color);
&:hover {
background-color: var(primary-color-hover);
}
}
&.fa-arrow-left,
&.fa-arrow-right {
color: gray;
opacity: 0.5;
pointer-events: none;
user-select: none;
}
&:hover {
background-color: var(side-nav-hover-color);
transition: background 0.2s ease-in;
}
&:active {
background-color: var(tertiary-text-color);
transition: background 0.2s ease-in;
@include top-nav-is-colored {
background-color: var(primary-color-active);
}
}
}
.navFilterIcon {
$effect-distance: 10px;
margin-left: $effect-distance;
&.filterChanged {
box-shadow: 0 0 $effect-distance var(primary-color);
@include top-nav-is-colored {
box-shadow: 0 0 $effect-distance var(text-with-main-color);
}
}
}
.side {
align-items: center;
display: flex;
gap: 3px;
margin: 0 6px;
&.profiles {
justify-content: flex-end;
}
.navSearchIcon {
@media only screen and (min-width: 681px) {
display: none;
}
}
.navNewWindowIcon {
@media only screen and (max-width: 680px) {
display: none;
}
}
.logo {
align-items: center;
cursor: pointer;
display: flex;
padding: 0 25px 0 10px;
&:active {
background-color: var(tertiary-text-color);
transition: background 0.2s ease-in;
@include top-nav-is-colored {
background-color: var(primary-color-active);
}
}
.logoIcon {
background-image: var(logo-icon);
background-position: right top;
background-repeat: no-repeat;
background-size: 25px;
height: 25px;
width: 25px;
@include top-nav-is-colored {
background-image: var(logo-icon-bar-color);
}
}
.logoText {
background-image: var(logo-text);
background-position: right top;
background-repeat: no-repeat;
background-size: 100px;
height: 40px;
margin-left: 5px;
position: relative;
top: -3px;
width: 100px;
@media only screen and (max-width: 680px) {
display: none;
}
@include top-nav-is-colored {
background-image: var(logo-text-bar-color);
}
}
}
}
.middle {
flex: 1;
max-width: 440px;
.searchContainer {
align-items: center;
display: flex;
@media only screen and (max-width: 680px) {
background-color: var(side-nav-color);
left: 0;
position: fixed;
right: 0;
top: 60px;
@include top-nav-is-colored {
background-color: var(primary-color-hover);
}
}
.searchInput {
flex: 1;
}
}
.searchFilters {
left: 0;
margin: 10px 20px 20px 220px;
position: absolute;
right: 0;
transition: margin 150ms ease-in-out;
@media only screen and (max-width: 680px) {
left: 0;
margin: 95px 10px 0;
right: 0;
}
}
}

View File

@ -108,4 +108,4 @@
</template>
<script src="./top-nav.js" />
<style scoped lang="sass" src="./top-nav.sass" />
<style scoped lang="scss" src="./top-nav.scss" />

View File

@ -53,25 +53,25 @@ export default Vue.extend({
this.$emit('timestamp-event', timestamp)
},
parseDescriptionHtml: function (descriptionText) {
descriptionText = descriptionText.replace(/target="_blank"/g, '')
descriptionText = descriptionText.replace(/\/redirect.+?(?=q=)/g, '')
descriptionText = descriptionText.replace(/q=/g, '')
descriptionText = descriptionText.replace(/rel="nofollow\snoopener"/g, '')
descriptionText = descriptionText.replace(/class=.+?(?=")./g, '')
descriptionText = descriptionText.replace(/id=.+?(?=")./g, '')
descriptionText = descriptionText.replace(/data-target-new-window=.+?(?=")./g, '')
descriptionText = descriptionText.replace(/data-url=.+?(?=")./g, '')
descriptionText = descriptionText.replace(/data-sessionlink=.+?(?=")./g, '')
descriptionText = descriptionText.replace(/&amp;/g, '&')
descriptionText = descriptionText.replace(/%3A/g, ':')
descriptionText = descriptionText.replace(/%2F/g, '/')
descriptionText = descriptionText.replace(/&v.+?(?=")/g, '')
descriptionText = descriptionText.replace(/&redirect-token.+?(?=")/g, '')
descriptionText = descriptionText.replace(/&redir_token.+?(?=")/g, '')
descriptionText = descriptionText.replace(/href="\//g, 'href="https://www.youtube.com/')
descriptionText = descriptionText.replaceAll('target="_blank"', '')
descriptionText = descriptionText.replaceAll(/\/redirect.+?(?=q=)/g, '')
descriptionText = descriptionText.replaceAll('q=', '')
descriptionText = descriptionText.replaceAll(/rel="nofollow\snoopener"/g, '')
descriptionText = descriptionText.replaceAll(/class=.+?(?=")./g, '')
descriptionText = descriptionText.replaceAll(/id=.+?(?=")./g, '')
descriptionText = descriptionText.replaceAll(/data-target-new-window=.+?(?=")./g, '')
descriptionText = descriptionText.replaceAll(/data-url=.+?(?=")./g, '')
descriptionText = descriptionText.replaceAll(/data-sessionlink=.+?(?=")./g, '')
descriptionText = descriptionText.replaceAll('&amp;', '&')
descriptionText = descriptionText.replaceAll('%3A', ':')
descriptionText = descriptionText.replaceAll('%2F', '/')
descriptionText = descriptionText.replaceAll(/&v.+?(?=")/g, '')
descriptionText = descriptionText.replaceAll(/&redirect-token.+?(?=")/g, '')
descriptionText = descriptionText.replaceAll(/&redir_token.+?(?=")/g, '')
descriptionText = descriptionText.replaceAll('href="/', 'href="https://www.youtube.com/')
// TODO: Implement hashtag support
descriptionText = descriptionText.replace(/href="\/hashtag\//g, 'href="freetube://')
descriptionText = descriptionText.replace(/yt\.www\.watch\.player\.seekTo/g, 'changeDuration')
descriptionText = descriptionText.replaceAll('href="/hashtag/', 'href="freetube://')
descriptionText = descriptionText.replaceAll('yt.www.watch.player.seekTo', 'changeDuration')
return descriptionText
}

View File

@ -264,7 +264,7 @@ export default Vue.extend({
const locale = this.currentLocale.replace('_', '-')
const localeDateString = new Intl.DateTimeFormat([locale, 'en'], { dateStyle: 'medium' }).format(date)
// replace spaces with no break spaces to make the date act as a single entity while wrapping
return `${localeDateString}`.replace(/ /g, '\u00A0')
return `${localeDateString}`.replaceAll(' ', '\u00A0')
},
publishedString() {

View File

@ -1,97 +0,0 @@
.watchVideoInfo
display: grid
grid-template-columns: auto minmax(min-content, 1fr)
column-gap: 15px
padding: 16px
@media screen and (max-width: 680px)
grid-template-columns: auto
.videoTitle
font-size: 22px
margin: 0 0 24px
word-break: break-word
display: block
margin-block-end: 1em
margin-inline-start: 0px
margin-inline-end: 0px
font-weight: normal
.channelInformation
.profileRow
display: flex
.channelThumbnail
border-radius: 50%
margin-right: 10px
cursor: pointer
width: 56px
.channelName
margin-left: 6px
cursor: pointer
position: relative
top: -2px
display: block
color: inherit
text-decoration: inherit
.subscribeButton
margin-top: 6px
margin-left: 6px
padding: 6px
font-size: 14px
.viewCount, .datePublished
color: var(--secondary-text-color)
text-align: right
font-size: 15px
@media screen and (max-width: 680px)
text-align: left
.viewCount
margin: 18px 0px 0px
.datePublished
margin: 4px 0px 0px
@media screen and (max-width: 680px)
margin-top: 16px
.likeSection
margin-top: 4px
font-size: 16px
color: var(--tertiary-text-color)
display: flex
flex-direction: column
margin-left: auto
text-align: right
max-width: 210px
@media screen and (max-width: 680px)
margin-left: 0
text-align: left
.likeBar
height: 8px
border-radius: 4px
margin-bottom: 4px
.likeCount
margin-right: 0px
.videoOptions
margin-top: 16px
display: flex
justify-content: flex-end
.option:not(:first-child)
margin-left: 4px
@media screen and (max-width: 680px)
justify-content: flex-start
:deep(.iconDropdown)
left: calc(50% - 20px)
right: auto

View File

@ -0,0 +1,119 @@
.watchVideoInfo {
column-gap: 15px;
display: grid;
grid-template-columns: auto minmax(min-content, 1fr);
padding: 16px;
@media screen and (max-width: 680px) {
grid-template-columns: auto;
}
}
.videoTitle {
display: block;
font-size: 22px;
font-weight: normal;
margin: 0 0 24px;
margin-block-end: 1em;
margin-inline-end: 0;
margin-inline-start: 0;
word-break: break-word;
}
.channelInformation {
.profileRow {
display: flex;
}
.channelThumbnail {
border-radius: 50%;
cursor: pointer;
margin-right: 10px;
width: 56px;
}
.channelName {
color: inherit;
cursor: pointer;
display: block;
margin-left: 6px;
position: relative;
text-decoration: inherit;
top: -2px;
}
.subscribeButton {
font-size: 14px;
margin-left: 6px;
margin-top: 6px;
padding: 6px;
}
}
.viewCount,
.datePublished {
color: var(secondary-text-color);
font-size: 15px;
text-align: right;
@media screen and (max-width: 680px) {
text-align: left;
}
}
.viewCount {
margin: 18px 0 0;
}
.datePublished {
margin: 4px 0 0;
@media screen and (max-width: 680px) {
margin-top: 16px;
}
}
.likeSection {
color: var(tertiary-text-color);
display: flex;
flex-direction: column;
font-size: 16px;
margin-left: auto;
margin-top: 4px;
max-width: 210px;
text-align: right;
@media screen and (max-width: 680px) {
margin-left: 0;
text-align: left;
}
.likeBar {
border-radius: 4px;
height: 8px;
margin-bottom: 4px;
}
.likeCount {
margin-right: 0;
}
}
.videoOptions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
.option:not(:first-child) {
margin-left: 4px;
}
@media screen and (max-width: 680px) {
justify-content: flex-start;
:deep(.iconDropdown) {
left: calc(50% - 20px);
right: auto;
}
}
}

View File

@ -130,4 +130,4 @@
</template>
<script src="./watch-video-info.js" />
<style scoped src="./watch-video-info.sass" lang="sass" />
<style scoped src="./watch-video-info.scss" lang="scss" />

View File

@ -40,5 +40,5 @@ export function handleDropdownKeyboardEvent(event, target, afterElement) {
}
export function sanitizeForHtmlId(attribute) {
return attribute.replace(/\s+/g, '')
return attribute.replaceAll(/\s+/g, '')
}

View File

@ -361,7 +361,7 @@ export function createWebURL(path) {
// strip html tags but keep <br>, <b>, </b> <s>, </s>, <i>, </i>
export function stripHTML(value) {
return value.replace(/(<(?!br|\/?(?:b|s|i)>)([^>]+)>)/ig, '')
return value.replaceAll(/(<(?!br|\/?[bis]>)([^>]+)>)/gi, '')
}
/**
@ -519,14 +519,14 @@ export function getVideoParamsFromUrl(url) {
},
// youtu.be
function () {
if (urlObject.host === 'youtu.be' && urlObject.pathname.match(/^\/[A-Za-z0-9_-]+$/)) {
if (urlObject.host === 'youtu.be' && /^\/[\w-]+$/.test(urlObject.pathname)) {
extractParams(urlObject.pathname.slice(1))
return paramsObject
}
},
// youtube.com/embed
function () {
if (urlObject.pathname.match(/^\/embed\/[A-Za-z0-9_-]+$/)) {
if (/^\/embed\/[\w-]+$/.test(urlObject.pathname)) {
const urlTail = urlObject.pathname.replace('/embed/', '')
if (urlTail === 'videoseries') {
paramsObject.playlistId = urlObject.searchParams.get('list')
@ -538,14 +538,14 @@ export function getVideoParamsFromUrl(url) {
},
// youtube.com/shorts
function () {
if (urlObject.pathname.match(/^\/shorts\/[A-Za-z0-9_-]+$/)) {
if (/^\/shorts\/[\w-]+$/.test(urlObject.pathname)) {
extractParams(urlObject.pathname.replace('/shorts/', ''))
return paramsObject
}
},
// cloudtube
function () {
if (urlObject.host.match(/^cadence\.(gq|moe)$/) && urlObject.pathname.match(/^\/cloudtube\/video\/[A-Za-z0-9_-]+$/)) {
if (/^cadence\.(gq|moe)$/.test(urlObject.host) && /^\/cloudtube\/video\/[\w-]+$/.test(urlObject.pathname)) {
extractParams(urlObject.pathname.slice('/cloudtube/video/'.length))
return paramsObject
}

View File

@ -27,7 +27,7 @@ class CustomVueI18n extends VueI18n {
// locales are only compressed in our production Electron builds
try {
// decompress brotli compressed json file and then load it
// eslint-disable-next-line node/no-path-concat
// eslint-disable-next-line n/no-path-concat
const compressed = await readFile(`${__dirname}/static/locales/${locale}.json.br`)
const decompressed = await brotliDecompressAsync(compressed)
const data = JSON.parse(decompressed.toString())

View File

@ -1,225 +0,0 @@
$thumbnail-overlay-opacity: 0.85
$watched-transition-duration: 0.5s
@mixin is-result
@at-root
.result#{&}
@content
@mixin is-watch-playlist-item
@at-root
.watchPlaylistItem#{&}
@content
@mixin is-recommendation
@at-root
.recommendation#{&}
@content
@mixin is-sidebar-item
@at-root
.watchPlaylistItem#{&}, .recommendation#{&}
@content
@mixin low-contrast-when-watched($col)
color: $col
@at-root
.watched &, .watched#{&}
color: var(--tertiary-text-color)
transition-duration: $watched-transition-duration
.watched:hover &, .watched:hover#{&}
color: $col
transition-duration: $watched-transition-duration
.ft-list-item
padding: 6px
&.watched
background-color: var(--bg-color)
@include low-contrast-when-watched(var(--primary-text-color))
.thumbnailImage
opacity: 0.3
transition-duration: $watched-transition-duration
&:hover .thumbnailImage
opacity: 1
transition-duration: $watched-transition-duration
.videoThumbnail
position: relative
.thumbnailLink
display: flex
.thumbnailImage
@include is-sidebar-item
height: 75px
@include is-recommendation
width: 163px
height: auto
.videoWatched
position: absolute
top:0
padding: 2px
opacity: $thumbnail-overlay-opacity
color: var(--primary-text-color)
background-color: var(--bg-color)
pointer-events: none
.videoDuration
position: absolute
bottom: 4px
right: 4px
padding: 3px 4px
line-height: 1.2
font-size: 15px
border-radius: 5px
margin: 0
opacity: $thumbnail-overlay-opacity
color: var(--primary-text-color)
background-color: var(--card-bg-color)
pointer-events: none
&.live
background-color: #f22
color: #fff
@include is-watch-playlist-item
font-size: 12px
.externalPlayerIcon
position: absolute
bottom: 4px
left: 4px
font-size: 17px
opacity: $thumbnail-overlay-opacity
.favoritesIcon
position: absolute
top: 3px
right: 3px
font-size: 17px
opacity: $thumbnail-overlay-opacity
.watchedProgressBar
height: 2px
position: absolute
bottom: 0px
background-color: var(--primary-color)
z-index: 2
max-width: 100%
.videoCountContainer
position: absolute
right: 0
top: 0
bottom: 0
width: 60px
font-size: 20px
.background, .inner
position: absolute
top: 0
bottom: 0
left: 0
right: 0
.background
background-color: var(--bg-color)
opacity: 0.9
.inner
display: flex
flex-direction: column
justify-content: center
align-items: center
color: var(--primary-text-color)
.channelThumbnail
display: flex
justify-content: center
.channelImage
height: 130px
border-radius: 50%
.info
flex: 1
position: relative
.optionsButton
float: right // ohhhh man, float was finally the right choice for something
.externalPlayerButton
float: right
.title
font-size: 20px
@include low-contrast-when-watched(var(--primary-text-color))
text-decoration: none
word-wrap: break-word
word-break: break-word
@include is-sidebar-item
font-size: 15px
.infoLine
margin-top: 5px
font-size: 14px
@include is-sidebar-item
font-size: 12px
&
@include low-contrast-when-watched(var(--secondary-text-color))
.channelName
@include low-contrast-when-watched(var(--secondary-text-color))
.description
font-size: 14px
max-height: 50px
overflow-y: hidden
@include low-contrast-when-watched(var(--secondary-text-color))
&.list
display: flex
align-items: flex-start
.videoThumbnail, .channelThumbnail
margin-right: 20px
.channelThumbnail
width: 231px
@include is-sidebar-item
.videoThumbnail
margin-right: 10px
&.grid
display: flex
flex-direction: column
min-height: 230px
padding-bottom: 20px
.videoThumbnail, .channelThumbnail
margin-bottom: 12px
.thumbnailImage
width: 100%
.title
font-size: 18px
.infoLine
margin-top: 8px
font-size: 13px
.videoWatched, .live
text-transform: uppercase

View File

@ -0,0 +1,292 @@
$thumbnail-overlay-opacity: 0.85;
$watched-transition-duration: 0.5s;
@mixin is-result {
@at-root {
.result#{&} {
@content;
}
}
}
@mixin is-watch-playlist-item {
@at-root {
.watchPlaylistItem#{&} {
@content;
}
}
}
@mixin is-recommendation {
@at-root {
.recommendation#{&} {
@content;
}
}
}
@mixin is-sidebar-item {
@at-root {
.watchPlaylistItem#{&},
.recommendation#{&} {
@content;
}
}
}
@mixin low-contrast-when-watched($col) {
color: $col;
@at-root {
.watched &,
.watched#{&} {
color: var(tertiary-text-color);
transition-duration: $watched-transition-duration;
}
.watched:hover &,
.watched:hover#{&} {
color: $col;
transition-duration: $watched-transition-duration;
}
}
}
.ft-list-item {
padding: 6px;
&.watched {
@include low-contrast-when-watched(var(primary-text-color));
background-color: var(bg-color);
.thumbnailImage {
opacity: 0.3;
transition-duration: $watched-transition-duration;
}
&:hover .thumbnailImage {
opacity: 1;
transition-duration: $watched-transition-duration;
}
}
.videoThumbnail {
position: relative;
.thumbnailLink {
display: flex;
}
.thumbnailImage {
@include is-sidebar-item {
height: 75px;
}
@include is-recommendation {
height: auto;
width: 163px;
}
}
.videoWatched {
background-color: var(bg-color);
color: var(primary-text-color);
opacity: $thumbnail-overlay-opacity;
padding: 2px;
pointer-events: none;
position: absolute;
top: 0;
}
.videoDuration {
background-color: var(card-bg-color);
border-radius: 5px;
bottom: 4px;
color: var(primary-text-color);
font-size: 15px;
line-height: 1.2;
margin: 0;
opacity: $thumbnail-overlay-opacity;
padding: 3px 4px;
pointer-events: none;
position: absolute;
right: 4px;
@include is-watch-playlist-item {
font-size: 12px;
}
&.live {
background-color: #f22;
color: #fff;
}
}
.externalPlayerIcon {
bottom: 4px;
font-size: 17px;
left: 4px;
opacity: $thumbnail-overlay-opacity;
position: absolute;
}
.favoritesIcon {
font-size: 17px;
opacity: $thumbnail-overlay-opacity;
position: absolute;
right: 3px;
top: 3px;
}
.watchedProgressBar {
background-color: var(primary-color);
bottom: 0;
height: 2px;
max-width: 100%;
position: absolute;
z-index: 2;
}
.videoCountContainer {
bottom: 0;
font-size: 20px;
position: absolute;
right: 0;
top: 0;
width: 60px;
.background,
.inner {
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
}
.background {
background-color: var(bg-color);
opacity: 0.9;
}
.inner {
align-items: center;
color: var(primary-text-color);
display: flex;
flex-direction: column;
justify-content: center;
}
}
}
.channelThumbnail {
display: flex;
justify-content: center;
.channelImage {
border-radius: 50%;
height: 130px;
}
}
.info {
flex: 1;
position: relative;
.optionsButton {
float: right; // ohhhh man, float was finally the right choice for something;
}
.externalPlayerButton {
float: right;
}
.title {
@include low-contrast-when-watched(var(primary-text-color));
font-size: 20px;
text-decoration: none;
word-break: break-word;
word-wrap: break-word;
@include is-sidebar-item {
font-size: 15px;
}
}
.infoLine {
font-size: 14px;
margin-top: 5px;
@include is-sidebar-item {
font-size: 12px;
}
& {
@include low-contrast-when-watched(var(secondary-text-color));
}
.channelName {
@include low-contrast-when-watched(var(secondary-text-color));
}
}
.description {
@include low-contrast-when-watched(var(secondary-text-color));
font-size: 14px;
max-height: 50px;
overflow-y: hidden;
}
}
&.list {
align-items: flex-start;
display: flex;
@include is-sidebar-item {
.videoThumbnail {
margin-right: 10px;
}
}
.videoThumbnail,
.channelThumbnail {
margin-right: 20px;
}
.channelThumbnail {
width: 231px;
}
}
&.grid {
display: flex;
flex-direction: column;
min-height: 230px;
padding-bottom: 20px;
.videoThumbnail,
.channelThumbnail {
margin-bottom: 12px;
.thumbnailImage {
width: 100%;
}
}
.title {
font-size: 18px;
}
.infoLine {
font-size: 13px;
margin-top: 8px;
}
}
}
.videoWatched,
.live {
text-transform: uppercase;
}

View File

@ -8,7 +8,7 @@ const modules = {}
files.keys().forEach(key => {
if (key === './index.js') return
modules[key.replace(/(\.\/|\.js)/g, '')] = files(key).default
modules[key.replaceAll(/(\.\/|\.js)/g, '')] = files(key).default
})
export default modules

View File

@ -314,7 +314,7 @@ const actions = {
const typePatterns = new Map([
['playlist', /^(\/playlist\/?|\/embed(\/?videoseries)?)$/],
['search', /^\/results\/?$/],
['hashtag', /^\/hashtag\/([^/?&#]+)$/],
['hashtag', /^\/hashtag\/([^#&/?]+)$/],
['channel', channelPattern]
])

View File

@ -1,50 +0,0 @@
.card
width: 85%
margin: 0 auto
margin-bottom: 60px
@media only screen and (max-width: 680px)
width: 90%
.brand
text-align: center
.logo
width: 500px
max-width: 100%
.version
font-size: 2em
.about-chunks
max-width: 860px
margin: 80px auto
display: grid
grid-template-columns: 1fr 1fr
grid-gap: 16px
@media only screen and (max-width: 650px)
grid-template-columns: 1fr
.chunk
background-color: var(--bg-color)
margin: 0
padding: 18px
border-radius: 8px
display: grid
grid-template: "icon title" auto "icon content" 1fr / auto 1fr
justify-content: start
align-items: start
grid-gap: 6px 14px
word-break: break-word
box-shadow: 0px 1px 4px -1px rgba(0, 0, 0, 0.5)
@each $area in icon title content
.#{$area}
grid-area: $area
.icon
font-size: 24px
.title
margin: 0

View File

@ -0,0 +1,62 @@
.card {
margin: 0 auto;
margin-bottom: 60px;
width: 85%;
@media only screen and (max-width: 680px) {
width: 90%;
}
}
.brand {
text-align: center;
}
.logo {
max-width: 100%;
width: 500px;
}
.version {
font-size: 2em;
}
.about-chunks {
display: grid;
grid-gap: 16px;
grid-template-columns: 1fr 1fr;
margin: 80px auto;
max-width: 860px;
@media only screen and (max-width: 650px) {
grid-template-columns: 1fr;
}
}
.chunk {
align-items: start;
background-color: var(bg-color);
border-radius: 8px;
box-shadow: 0 1px 4px -1px rgb(0 0 0 / 50%);
display: grid;
grid-gap: 6px 14px;
grid-template: 'icon title' auto 'icon content' 1fr / auto 1fr;
justify-content: start;
margin: 0;
padding: 18px;
word-break: break-word;
@each $area in icon title content {
.#{$area} {
grid-area: $area;
}
}
.icon {
font-size: 24px;
}
.title {
margin: 0;
}
}

View File

@ -37,4 +37,4 @@
</template>
<script src="./About.js" />
<style scoped src="./About.sass" lang="sass" />
<style scoped src="./About.scss" lang="scss" />

View File

@ -23,7 +23,7 @@ export default Vue.extend({
subscribedChannels: [],
filteredChannels: [],
re: {
url: /(.+=\w{1})\d+(.+)/,
url: /(.+=\w)\d+(.+)/,
ivToIv: /^.+(ggpht.+)/,
ivToYt: /^.+ggpht\/(.+)/,
ytToIv: /^.+ggpht\.com\/(.+)/
@ -115,7 +115,7 @@ export default Vue.extend({
return
}
const escapedQuery = this.query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const escapedQuery = this.query.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&')
const re = new RegExp(escapedQuery, 'i')
this.filteredChannels = this.subscribedChannels.filter(channel => {
return re.test(channel.name)

View File

@ -260,9 +260,9 @@ export default Vue.extend({
let skipIndex
errorScreen.subreason.runs.forEach((message, index) => {
if (index !== skipIndex) {
if (message.text.match(/<a.*>/)) {
if (/<a.*>/.test(message.text)) {
skipIndex = index + 1
} else if (!message.text.match(/<\/a>/)) {
} else if (!/<\/a>/.test(message.text)) {
if (typeof subReason === 'undefined') {
subReason = message.text
} else {
@ -719,7 +719,7 @@ export default Vue.extend({
// MM:SS - Text
// HH:MM:SS - HH:MM:SS - Text // end timestamp is ignored, separator is one of '-', '', '—'
// HH:MM - HH:MM - Text // end timestamp is ignored
const chapterMatches = result.description.matchAll(/^(?<timestamp>((?<hours>[0-9]+):)?(?<minutes>[0-9]+):(?<seconds>[0-9]+))(\s*[-–—]\s*(?:[0-9]+:)?[0-9]+:[0-9]+)?\s+([-–•—]\s*)?(?<title>.+)$/gm)
const chapterMatches = result.description.matchAll(/^(?<timestamp>((?<hours>\d+):)?(?<minutes>\d+):(?<seconds>\d+))(\s*[–—-]\s*(?:\d+:){1,2}\d+)?\s+([–—•-]\s*)?(?<title>.+)$/gm)
for (const { groups } of chapterMatches) {
let start = 60 * Number(groups.minutes) + Number(groups.seconds)
@ -860,7 +860,7 @@ export default Vue.extend({
},
processDescriptionPart(part, fallbackDescription) {
const timestampRegex = /^([0-9]+:)?[0-9]+:[0-9]+$/
const timestampRegex = /^(\d+:)?\d+:\d+$/
if (typeof part.navigationEndpoint === 'undefined' || part.navigationEndpoint === null || part.text.startsWith('#')) {
return part.text
@ -1314,7 +1314,7 @@ export default Vue.extend({
/* eslint-disable-next-line */
const [width, height, count, sWidth, sHeight, interval, _, sigh] = storyboard.split('#')
storyboardArray.push({
url: baseUrl.replace('$L', i + 1).replace('$N', 'M0').replace(/<\/?sub>/g, '') + '&sigh=' + sigh,
url: baseUrl.replace('$L', i + 1).replace('$N', 'M0').replaceAll(/<\/?sub>/g, '') + '&sigh=' + sigh,
width: Number(width), // Width of one sub image
height: Number(height), // Height of one sub image
sWidth: Number(sWidth), // Number of images vertically (if full)
@ -1412,7 +1412,7 @@ export default Vue.extend({
// The character '#' needs to be percent-encoded in a (data) URI
// because it signals an identifier, which means anything after it
// is automatically removed when the URI is used as a source
let vtt = text.replace(/#/g, '%23')
let vtt = text.replaceAll('#', '%23')
// A lot of videos have messed up caption positions that need to be removed
// This can be either because this format isn't really used by YouTube
@ -1424,9 +1424,9 @@ export default Vue.extend({
// In addition, all aligns seem to be fixed to "start" when they do pop up in normal captions
// If it's prominent enough that people start to notice, it can be removed then
if (caption.kind === 'asr') {
vtt = vtt.replace(/ align:start| position:\d{1,3}%/g, '')
vtt = vtt.replaceAll(/ align:start| position:\d{1,3}%/g, '')
} else {
vtt = vtt.replace(/ position:\d{1,3}%/g, '')
vtt = vtt.replaceAll(/ position:\d{1,3}%/g, '')
}
caption.baseUrl = `data:${caption.type};${caption.charset},${vtt}`

View File

@ -1,109 +0,0 @@
=dual-column-template
grid-template: "video video sidebar" 0fr "info info sidebar" auto "info info sidebar" 1fr / 1fr 1fr 1fr
=theatre-mode-template
grid-template: "video video video" auto "info info sidebar" auto "info info sidebar" auto / 1fr 1fr 1fr
=single-column-template
grid-template: "video" auto "info" auto "sidebar" auto / auto
.ageRestricted
max-width: calc(80vh * 1.78)
display: inline-block
+single-column-template
@media only screen and (min-width: 901px)
width: 300%
.videoLayout
display: grid
align-items: start
+dual-column-template
@media only screen and (max-width: 1350px)
+theatre-mode-template
@media only screen and (min-width: 901px)
&.useTheatreMode
+theatre-mode-template
@media only screen and (max-width: 900px)
+single-column-template
&.isLoading, &.noSidebar
+single-column-template
.videoArea
grid-area: video
.videoAreaMargin
margin: 0 0 16px
.videoPlayer
grid-column: 1
max-width: calc(80vh * 1.78)
margin: 0 auto
position: relative
.upcomingThumbnail
width: 100%
.premiereDate
color: #FFFFFF
background-color: rgba(0, 0, 0, 0.8)
height: 60px
border-radius: 5px
position: absolute
bottom: 12px
left: 12px
display: flex
align-items: center
padding: 0 12px
.premiereIcon
float: left
font-size: 25px
margin: 0 12px
.premiereText
min-width: 200px
margin: 0 12px
.premiereTextTimestamp
font-size: 0.85em
font-weight: bold
.watchVideo
margin: 0 0 16px
grid-column: 1
.infoArea
grid-area: info
position: relative
@media only screen and (min-width: 901px)
.infoArea
scroll-margin-top: 76px
.infoAreaSticky
position: sticky
top: 76px
.sidebarArea
grid-area: sidebar
@media only screen and (min-width: 901px)
min-width: 380px
@at-root .noSidebar#{&}
grid-area: auto
.watchVideoPlaylist, .watchVideoSidebar, .theatrePlaylist
margin: 0 8px 16px
.watchVideoSidebar, .watchVideoPlaylist
height: 500px
.watchVideoRecommendations, .theatreRecommendations
margin: 0 0 16px
@media only screen and (min-width: 901px)
margin: 0 8px 16px

View File

@ -0,0 +1,149 @@
@mixin dual-column-template {
grid-template: 'video video sidebar' 0fr 'info info sidebar' auto 'info info sidebar' 1fr / 1fr 1fr 1fr;
}
@mixin theatre-mode-template {
grid-template: 'video video video' auto 'info info sidebar' auto 'info info sidebar' auto / 1fr 1fr 1fr;
}
@mixin single-column-template {
grid-template: 'video' auto 'info' auto 'sidebar' auto / auto;
}
.ageRestricted {
@include single-column-template;
display: inline-block;
max-width: calc(80vh * 1.78);
@media only screen and (min-width: 901px) {
width: 300%;
}
}
.videoLayout {
@include dual-column-template;
align-items: start;
display: grid;
&.isLoading,
&.noSidebar {
@include single-column-template;
}
.videoArea {
grid-area: video;
.videoAreaMargin {
margin: 0 0 16px;
}
.videoPlayer {
grid-column: 1;
margin: 0 auto;
max-width: calc(80vh * 1.78);
position: relative;
.upcomingThumbnail {
width: 100%;
}
.premiereDate {
align-items: center;
background-color: rgb(0 0 0 / 80%);
border-radius: 5px;
bottom: 12px;
color: #fff;
display: flex;
height: 60px;
left: 12px;
padding: 0 12px;
position: absolute;
.premiereIcon {
float: left;
font-size: 25px;
margin: 0 12px;
}
.premiereText {
margin: 0 12px;
min-width: 200px;
.premiereTextTimestamp {
font-size: 0.85em;
font-weight: bold;
}
}
}
}
}
.watchVideo {
grid-column: 1;
margin: 0 0 16px;
}
.infoArea {
grid-area: info;
position: relative;
}
.sidebarArea {
grid-area: sidebar;
@media only screen and (min-width: 901px) {
min-width: 380px;
}
@at-root .noSidebar#{&} {
grid-area: auto;
}
}
.watchVideoPlaylist,
.watchVideoSidebar,
.theatrePlaylist {
margin: 0 8px 16px;
}
.watchVideoSidebar,
.watchVideoPlaylist {
height: 500px;
}
.watchVideoRecommendations,
.theatreRecommendations {
margin: 0 0 16px;
@media only screen and (min-width: 901px) {
margin: 0 8px 16px;
}
}
@media only screen and (max-width: 1350px) {
@include theatre-mode-template;
}
@media only screen and (min-width: 901px) {
&.useTheatreMode {
@include theatre-mode-template;
}
}
@media only screen and (max-width: 900px) {
@include single-column-template;
}
@media only screen and (min-width: 901px) {
.infoArea {
scroll-margin-top: 76px;
}
.infoAreaSticky {
position: sticky;
top: 76px;
}
}
}

View File

@ -185,4 +185,4 @@
</template>
<script src="./Watch.js" />
<style scoped src="./Watch.sass" lang="sass" />
<style scoped src="./Watch.scss" lang="scss" />

View File

@ -193,5 +193,3 @@
{"id":894,"name":"Замбия","alpha2":"zm","alpha3":"zmb"},
{"id":716,"name":"Зимбабве","alpha2":"zw","alpha3":"zwe"},
{"id":158,"name":"Тайван","alpha2":"tw","alpha3":"twn"}]

View File

@ -191,4 +191,4 @@
{"id":752,"name":"Швеція","alpha2":"se","alpha3":"swe"},
{"id":144,"name":"Шрі-Ланка","alpha2":"lk","alpha3":"lka"},
{"id":388,"name":"Ямайка","alpha2":"jm","alpha3":"jam"},
{"id":392,"name":"Японія","alpha2":"jp","alpha3":"jpn"}]
{"id":392,"name":"Японія","alpha2":"jp","alpha3":"jpn"}]

961
yarn.lock

File diff suppressed because it is too large Load Diff