mirror of
https://github.com/Tzahi12345/YoutubeDL-Material.git
synced 2026-04-15 01:21:30 +03:00
Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into download-manager
This commit is contained in:
@@ -31,7 +31,8 @@
|
|||||||
"youtube_API_key": "",
|
"youtube_API_key": "",
|
||||||
"use_twitch_API": false,
|
"use_twitch_API": false,
|
||||||
"twitch_API_key": "",
|
"twitch_API_key": "",
|
||||||
"twitch_auto_download_chat": false
|
"twitch_auto_download_chat": false,
|
||||||
|
"use_sponsorblock_API": false
|
||||||
},
|
},
|
||||||
"Themes": {
|
"Themes": {
|
||||||
"default_theme": "default",
|
"default_theme": "default",
|
||||||
|
|||||||
@@ -208,7 +208,8 @@ DEFAULT_CONFIG = {
|
|||||||
"youtube_API_key": "",
|
"youtube_API_key": "",
|
||||||
"use_twitch_API": false,
|
"use_twitch_API": false,
|
||||||
"twitch_API_key": "",
|
"twitch_API_key": "",
|
||||||
"twitch_auto_download_chat": false
|
"twitch_auto_download_chat": false,
|
||||||
|
"use_sponsorblock_API": false
|
||||||
},
|
},
|
||||||
"Themes": {
|
"Themes": {
|
||||||
"default_theme": "default",
|
"default_theme": "default",
|
||||||
@@ -217,7 +218,7 @@ DEFAULT_CONFIG = {
|
|||||||
"Subscriptions": {
|
"Subscriptions": {
|
||||||
"allow_subscriptions": true,
|
"allow_subscriptions": true,
|
||||||
"subscriptions_base_path": "subscriptions/",
|
"subscriptions_base_path": "subscriptions/",
|
||||||
"subscriptions_check_interval": "300",
|
"subscriptions_check_interval": "86400",
|
||||||
"redownload_fresh_uploads": false,
|
"redownload_fresh_uploads": false,
|
||||||
"download_delay": ""
|
"download_delay": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ let CONFIG_ITEMS = {
|
|||||||
'key': 'ytdl_twitch_auto_download_chat',
|
'key': 'ytdl_twitch_auto_download_chat',
|
||||||
'path': 'YoutubeDLMaterial.API.twitch_auto_download_chat'
|
'path': 'YoutubeDLMaterial.API.twitch_auto_download_chat'
|
||||||
},
|
},
|
||||||
|
'ytdl_use_sponsorblock_api': {
|
||||||
|
'key': 'ytdl_use_sponsorblock_api',
|
||||||
|
'path': 'YoutubeDLMaterial.API.use_sponsorblock_API'
|
||||||
|
},
|
||||||
|
|
||||||
// Themes
|
// Themes
|
||||||
'ytdl_default_theme': {
|
'ytdl_default_theme': {
|
||||||
|
|||||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -3800,6 +3800,11 @@
|
|||||||
"randomfill": "^1.0.3"
|
"randomfill": "^1.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"crypto-js": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw=="
|
||||||
|
},
|
||||||
"css": {
|
"css": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/css/-/css-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/css/-/css-2.2.4.tgz",
|
||||||
@@ -13743,7 +13748,8 @@
|
|||||||
},
|
},
|
||||||
"ssri": {
|
"ssri": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "",
|
"resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"figgy-pudding": "^3.5.1"
|
"figgy-pudding": "^3.5.1"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"@ngneat/content-loader": "^5.0.0",
|
"@ngneat/content-loader": "^5.0.0",
|
||||||
"@videogular/ngx-videogular": "^2.1.0",
|
"@videogular/ngx-videogular": "^2.1.0",
|
||||||
"core-js": "^2.4.1",
|
"core-js": "^2.4.1",
|
||||||
|
"crypto-js": "^4.1.1",
|
||||||
"file-saver": "^2.0.2",
|
"file-saver": "^2.0.2",
|
||||||
"filesize": "^6.1.0",
|
"filesize": "^6.1.0",
|
||||||
"fingerprintjs2": "^2.1.0",
|
"fingerprintjs2": "^2.1.0",
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ import { TwitchChatComponent } from './components/twitch-chat/twitch-chat.compon
|
|||||||
import { LinkifyPipe, SeeMoreComponent } from './components/see-more/see-more.component';
|
import { LinkifyPipe, SeeMoreComponent } from './components/see-more/see-more.component';
|
||||||
import { H401Interceptor } from './http.interceptor';
|
import { H401Interceptor } from './http.interceptor';
|
||||||
import { ConcurrentStreamComponent } from './components/concurrent-stream/concurrent-stream.component';
|
import { ConcurrentStreamComponent } from './components/concurrent-stream/concurrent-stream.component';
|
||||||
|
import { SkipAdButtonComponent } from './components/skip-ad-button/skip-ad-button.component';
|
||||||
|
|
||||||
registerLocaleData(es, 'es');
|
registerLocaleData(es, 'es');
|
||||||
|
|
||||||
@@ -136,7 +137,8 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
|
|||||||
EditCategoryDialogComponent,
|
EditCategoryDialogComponent,
|
||||||
TwitchChatComponent,
|
TwitchChatComponent,
|
||||||
SeeMoreComponent,
|
SeeMoreComponent,
|
||||||
ConcurrentStreamComponent
|
ConcurrentStreamComponent,
|
||||||
|
SkipAdButtonComponent
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<button *ngIf="show_skip_ad_button" (click)="skipAdButtonClicked()" mat-flat-button><ng-container i18n="Skip ad button">Skip ad</ng-container></button>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { SkipAdButtonComponent } from './skip-ad-button.component';
|
||||||
|
|
||||||
|
describe('SkipAdButtonComponent', () => {
|
||||||
|
let component: SkipAdButtonComponent;
|
||||||
|
let fixture: ComponentFixture<SkipAdButtonComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ SkipAdButtonComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(SkipAdButtonComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
107
src/app/components/skip-ad-button/skip-ad-button.component.ts
Normal file
107
src/app/components/skip-ad-button/skip-ad-button.component.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||||
|
import { PostsService } from 'app/posts.services';
|
||||||
|
import CryptoJS from 'crypto-js';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-skip-ad-button',
|
||||||
|
templateUrl: './skip-ad-button.component.html',
|
||||||
|
styleUrls: ['./skip-ad-button.component.scss']
|
||||||
|
})
|
||||||
|
export class SkipAdButtonComponent implements OnInit {
|
||||||
|
|
||||||
|
@Input() current_video = null;
|
||||||
|
@Input() playback_timestamp = null;
|
||||||
|
|
||||||
|
@Output() setPlaybackTimestamp = new EventEmitter<any>();
|
||||||
|
|
||||||
|
sponsor_block_cache = {};
|
||||||
|
show_skip_ad_button = false;
|
||||||
|
|
||||||
|
constructor(private postsService: PostsService) { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
setInterval(() => this.skipAdButtonCheck(), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkSponsorBlock(video_to_check) {
|
||||||
|
if (!video_to_check) return;
|
||||||
|
|
||||||
|
// check cache, null means it has been checked and confirmed not to exist (limits API calls)
|
||||||
|
if (this.sponsor_block_cache[video_to_check.url] || this.sponsor_block_cache[video_to_check.url] === null) return;
|
||||||
|
|
||||||
|
// sponsor block needs first 4 chars from video ID hash
|
||||||
|
const video_id = this.getVideoIDFromURL(video_to_check.url);
|
||||||
|
const id_hash = this.getVideoIDHashFromURL(video_id);
|
||||||
|
if (!id_hash || id_hash.length < 4) return;
|
||||||
|
const truncated_id_hash = id_hash.substring(0, 4);
|
||||||
|
|
||||||
|
// we couldn't get the data from the cache, let's get it from sponsor block directly
|
||||||
|
|
||||||
|
this.postsService.getSponsorBlockDataForVideo(truncated_id_hash).subscribe(res => {
|
||||||
|
if (res && res['length'] && res['length'] === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const found_data = res['find'](data => data['videoID'] === video_id);
|
||||||
|
if (found_data) {
|
||||||
|
this.sponsor_block_cache[video_to_check.url] = found_data;
|
||||||
|
}
|
||||||
|
}, err => {
|
||||||
|
// likely doesn't exist
|
||||||
|
this.sponsor_block_cache[video_to_check.url] = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getVideoIDHashFromURL(video_id) {
|
||||||
|
if (!video_id) return null;
|
||||||
|
return CryptoJS.SHA256(video_id).toString(CryptoJS.enc.Hex);;
|
||||||
|
}
|
||||||
|
|
||||||
|
getVideoIDFromURL(url) {
|
||||||
|
const regex_exp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/;
|
||||||
|
const match = url.match(regex_exp);
|
||||||
|
return (match && match[7].length==11) ? match[7] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
skipAdButtonCheck() {
|
||||||
|
const sponsor_block_data = this.sponsor_block_cache[this.current_video.url];
|
||||||
|
if (!sponsor_block_data && sponsor_block_data !== null) {
|
||||||
|
// we haven't yet tried to get the sponsor block data for the video
|
||||||
|
this.checkSponsorBlock(this.current_video);
|
||||||
|
} else if (!sponsor_block_data) {
|
||||||
|
this.show_skip_ad_button = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.getTimeToSkipTo()) {
|
||||||
|
this.show_skip_ad_button = true;
|
||||||
|
} else {
|
||||||
|
this.show_skip_ad_button = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getTimeToSkipTo() {
|
||||||
|
const sponsor_block_data = this.sponsor_block_cache[this.current_video.url];
|
||||||
|
|
||||||
|
if (!sponsor_block_data) return;
|
||||||
|
|
||||||
|
// check if we're in between an ad segment
|
||||||
|
const found_segment = sponsor_block_data['segments'].find(segment_data => this.playback_timestamp > segment_data.segment[0] && this.playback_timestamp < segment_data.segment[1] - 0.5);
|
||||||
|
|
||||||
|
if (found_segment) {
|
||||||
|
return found_segment['segment'][1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
skipAdButtonClicked() {
|
||||||
|
const time_to_skip_to = this.getTimeToSkipTo();
|
||||||
|
if (!time_to_skip_to) return;
|
||||||
|
|
||||||
|
this.setPlaybackTimestamp.emit(time_to_skip_to);
|
||||||
|
|
||||||
|
this.show_skip_ad_button = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -648,7 +648,6 @@ export class MainComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.cachedAvailableFormats[url]['formats'] = this.getAudioAndVideoFormats(infos.formats);
|
this.cachedAvailableFormats[url]['formats'] = this.getAudioAndVideoFormats(infos.formats);
|
||||||
console.log(this.cachedAvailableFormats[url]['formats']);
|
|
||||||
}, err => {
|
}, err => {
|
||||||
this.errorFormats(url);
|
this.errorFormats(url);
|
||||||
});
|
});
|
||||||
@@ -808,8 +807,6 @@ export class MainComponent implements OnInit {
|
|||||||
const audio_formats: any = {};
|
const audio_formats: any = {};
|
||||||
const video_formats: any = {};
|
const video_formats: any = {};
|
||||||
|
|
||||||
console.log(formats);
|
|
||||||
|
|
||||||
for (let i = 0; i < formats.length; i++) {
|
for (let i = 0; i < formats.length; i++) {
|
||||||
const format_obj = {type: null};
|
const format_obj = {type: null};
|
||||||
|
|
||||||
|
|||||||
@@ -89,4 +89,10 @@
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-ad-button {
|
||||||
|
position: absolute;
|
||||||
|
right: 20px;
|
||||||
|
bottom: 75px;
|
||||||
}
|
}
|
||||||
@@ -4,8 +4,9 @@
|
|||||||
<mat-drawer-container style="height: 100%" class="example-container" autosize>
|
<mat-drawer-container style="height: 100%" class="example-container" autosize>
|
||||||
<div style="height: fit-content" [ngClass]="(currentItem.type === 'audio/mp3') ? 'audio-col' : 'video-col'">
|
<div style="height: fit-content" [ngClass]="(currentItem.type === 'audio/mp3') ? 'audio-col' : 'video-col'">
|
||||||
<vg-player style="height: fit-content; max-height: 75vh" (onPlayerReady)="onPlayerReady($event)" [style.background-color]="(currentItem.type === 'audio/mp3') ? postsService.theme.drawer_color : 'black'">
|
<vg-player style="height: fit-content; max-height: 75vh" (onPlayerReady)="onPlayerReady($event)" [style.background-color]="(currentItem.type === 'audio/mp3') ? postsService.theme.drawer_color : 'black'">
|
||||||
<video [ngClass]="(currentItem.type === 'audio/mp3') ? 'audio-styles' : 'video-styles'" #media class="video-player" [vgMedia]="media" [src]="currentItem.src" id="singleVideo" preload="auto" controls>
|
<video [ngClass]="(currentItem.type === 'audio/mp3') ? 'audio-styles' : 'video-styles'" #media class="video-player" [vgMedia]="media" [src]="currentItem.src" id="singleVideo" preload="auto" controls playsinline>
|
||||||
</video>
|
</video>
|
||||||
|
<app-skip-ad-button *ngIf="postsService['config']['API']['use_sponsorblock_API'] && api && playlist?.length > 0 && playlist[currentIndex]['type'] === 'video/mp4'" (setPlaybackTimestamp)="setPlaybackTimestamp($event)" [current_video]="playlist[currentIndex]" [playback_timestamp]="api.currentTime" [sponsor_block_cache]="sponsor_block_cache" class="skip-ad-button"></app-skip-ad-button>
|
||||||
</vg-player>
|
</vg-player>
|
||||||
</div>
|
</div>
|
||||||
<div style="height: fit-content; width: 100%; margin-top: 10px;">
|
<div style="height: fit-content; width: 100%; margin-top: 10px;">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, HostListener, EventEmitter, OnDestroy, AfterViewInit, ViewChild, ChangeDetectorRef } from '@angular/core';
|
import { Component, OnInit, HostListener, OnDestroy, AfterViewInit, ViewChild, ChangeDetectorRef } from '@angular/core';
|
||||||
import { VgApiService } from '@videogular/ngx-videogular/core';
|
import { VgApiService } from '@videogular/ngx-videogular/core';
|
||||||
import { PostsService } from 'app/posts.services';
|
import { PostsService } from 'app/posts.services';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
@@ -15,6 +15,7 @@ export interface IMedia {
|
|||||||
src: string;
|
src: string;
|
||||||
type: string;
|
type: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -133,7 +134,8 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
title: this.name,
|
title: this.name,
|
||||||
label: this.name,
|
label: this.name,
|
||||||
src: this.url,
|
src: this.url,
|
||||||
type: 'video/mp4'
|
type: 'video/mp4',
|
||||||
|
url: this.url
|
||||||
}
|
}
|
||||||
this.playlist.push(imedia);
|
this.playlist.push(imedia);
|
||||||
this.currentItem = this.playlist[0];
|
this.currentItem = this.playlist[0];
|
||||||
@@ -229,7 +231,8 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
title: file_obj['title'],
|
title: file_obj['title'],
|
||||||
src: fullLocation,
|
src: fullLocation,
|
||||||
type: mime_type,
|
type: mime_type,
|
||||||
label: file_obj['title']
|
label: file_obj['title'],
|
||||||
|
url: file_obj['url']
|
||||||
}
|
}
|
||||||
this.playlist.push(mediaObject);
|
this.playlist.push(mediaObject);
|
||||||
}
|
}
|
||||||
@@ -289,13 +292,6 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.currentItem = item;
|
this.currentItem = item;
|
||||||
}
|
}
|
||||||
|
|
||||||
getFileInfos() {
|
|
||||||
const fileNames = this.getFileNames();
|
|
||||||
this.postsService.getFileInfo(fileNames, this.type, false).subscribe(res => {
|
|
||||||
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getFileNames() {
|
getFileNames() {
|
||||||
const fileNames = [];
|
const fileNames = [];
|
||||||
for (let i = 0; i < this.playlist.length; i++) {
|
for (let i = 0; i < this.playlist.length; i++) {
|
||||||
|
|||||||
@@ -614,6 +614,11 @@ export class PostsService implements CanActivate {
|
|||||||
this.httpOptions);
|
this.httpOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSponsorBlockDataForVideo(id_hash) {
|
||||||
|
const sponsor_block_api_path = 'https://sponsor.ajay.app/api/';
|
||||||
|
return this.http.get(sponsor_block_api_path + `skipSegments/${id_hash}`);
|
||||||
|
}
|
||||||
|
|
||||||
public openSnackBar(message: string, action: string = '') {
|
public openSnackBar(message: string, action: string = '') {
|
||||||
this.snackBar.open(message, action, {
|
this.snackBar.open(message, action, {
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
|
|||||||
@@ -266,12 +266,15 @@
|
|||||||
<div *ngIf="new_config['API']['use_twitch_API']" class="col-12 mt-1">
|
<div *ngIf="new_config['API']['use_twitch_API']" class="col-12 mt-1">
|
||||||
<mat-checkbox color="accent" [(ngModel)]="new_config['API']['twitch_auto_download_chat']"><ng-container i18n="Auto download Twitch Chat setting">Auto-download Twitch Chat</ng-container></mat-checkbox>
|
<mat-checkbox color="accent" [(ngModel)]="new_config['API']['twitch_auto_download_chat']"><ng-container i18n="Auto download Twitch Chat setting">Auto-download Twitch Chat</ng-container></mat-checkbox>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 mb-5">
|
<div class="col-12">
|
||||||
<mat-form-field class="text-field" color="accent">
|
<mat-form-field class="text-field" color="accent">
|
||||||
<input [disabled]="!new_config['API']['use_twitch_API']" [(ngModel)]="new_config['API']['twitch_API_key']" matInput placeholder="Twitch API Key" i18n-placeholder="Twitch API Key setting placeholder" required>
|
<input [disabled]="!new_config['API']['use_twitch_API']" [(ngModel)]="new_config['API']['twitch_API_key']" matInput placeholder="Twitch API Key" i18n-placeholder="Twitch API Key setting placeholder" required>
|
||||||
<mat-hint><ng-container i18n="Twitch API Key setting hint AKA preamble">Also known as a Client ID.</ng-container> <a target="_blank" href="https://dev.twitch.tv/docs/api/"><ng-container i18n="Twitch API Key setting hint">Generating a key is easy!</ng-container></a></mat-hint>
|
<mat-hint><ng-container i18n="Twitch API Key setting hint AKA preamble">Also known as a Client ID.</ng-container> <a target="_blank" href="https://dev.twitch.tv/docs/api/"><ng-container i18n="Twitch API Key setting hint">Generating a key is easy!</ng-container></a></mat-hint>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-12 mt-4 mb-3">
|
||||||
|
<mat-checkbox color="accent" [(ngModel)]="new_config['API']['use_sponsorblock_API']" matTooltip="Enables a button to skip ads when viewing supported videos." i18n-matTooltip="SponsorBlock API tooltip"><ng-container i18n="Use SponsorBlock API setting">Use SponsorBlock API</ng-container></mat-checkbox>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<mat-divider></mat-divider>
|
<mat-divider></mat-divider>
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-field {
|
.text-field {
|
||||||
min-width: 30%;
|
width: 95%;
|
||||||
|
max-width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-button {
|
.checkbox-button {
|
||||||
|
|||||||
Reference in New Issue
Block a user