This commit is contained in:
parent
d3fbda5e74
commit
42312cf409
@ -8,11 +8,11 @@ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
|||||||
|
|
||||||
#MAC
|
#MAC
|
||||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
docker-compose -f "${SCRIPT_DIR}/../../docker/docker-compose-mac.yml" $COMMAND
|
docker compose -f "${SCRIPT_DIR}/../../docker/docker-compose-mac.yml" $COMMAND
|
||||||
|
|
||||||
#LINUX
|
#LINUX
|
||||||
elif [[ "$OSTYPE" == "linux-gnu" ]]; then
|
elif [[ "$OSTYPE" == "linux-gnu" ]]; then
|
||||||
docker-compose -f "${SCRIPT_DIR}/../../docker/docker-compose.yml" $COMMAND
|
docker compose -f "${SCRIPT_DIR}/../../docker/docker-compose.yml" $COMMAND
|
||||||
|
|
||||||
else
|
else
|
||||||
echo "Dieses Skript wird auf deinem Gerät nicht unterstützt"
|
echo "Dieses Skript wird auf deinem Gerät nicht unterstützt"
|
||||||
|
|||||||
693
package-lock.json
generated
693
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -35,6 +35,7 @@
|
|||||||
"karma-coverage": "~2.2.0",
|
"karma-coverage": "~2.2.0",
|
||||||
"karma-jasmine": "~5.1.0",
|
"karma-jasmine": "~5.1.0",
|
||||||
"karma-jasmine-html-reporter": "~2.0.0",
|
"karma-jasmine-html-reporter": "~2.0.0",
|
||||||
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "~4.9.4"
|
"typescript": "~4.9.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
|
<app-navigation></app-navigation>
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
@ -6,18 +6,26 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
|||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
import { RouterModule, Routes } from '@angular/router';
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
import { SharedModule } from './shared/shared.module';
|
import { SharedModule } from './shared/shared.module';
|
||||||
|
import { NavigationComponent } from './component/navigation/navigation.component';
|
||||||
|
import { HomeComponent } from './component/home/home.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: 'video', loadChildren: () => import('./feature/video/video.module').then( m => m.VideoModule ) },
|
{ path: 'home', component: HomeComponent },
|
||||||
{ path: 'tag', loadChildren: () => import('./feature/tag/tag.module').then( m => m.TagModule ) },
|
{
|
||||||
{ path: '', redirectTo: 'video/list', pathMatch: 'full' }
|
path: 'video',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./feature/video/video.module').then((m) => m.VideoModule),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tag',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./feature/tag/tag.module').then((m) => m.TagModule),
|
||||||
|
},
|
||||||
|
{ path: '', redirectTo: 'home', pathMatch: 'full' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [AppComponent, NavigationComponent, HomeComponent],
|
||||||
AppComponent,
|
|
||||||
],
|
|
||||||
imports: [
|
imports: [
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
BrowserAnimationsModule,
|
BrowserAnimationsModule,
|
||||||
@ -26,6 +34,6 @@ const routes: Routes = [
|
|||||||
SharedModule,
|
SharedModule,
|
||||||
],
|
],
|
||||||
providers: [],
|
providers: [],
|
||||||
bootstrap: [AppComponent]
|
bootstrap: [AppComponent],
|
||||||
})
|
})
|
||||||
export class AppModule { }
|
export class AppModule {}
|
||||||
|
|||||||
5
src/app/component/home/home.component.html
Normal file
5
src/app/component/home/home.component.html
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<shared-video-list
|
||||||
|
(onReadList)="readList($event)"
|
||||||
|
[total]="total"
|
||||||
|
[videos]="videos"
|
||||||
|
/>
|
||||||
0
src/app/component/home/home.component.scss
Normal file
0
src/app/component/home/home.component.scss
Normal file
31
src/app/component/home/home.component.ts
Normal file
31
src/app/component/home/home.component.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { VideoListEntry } from 'src/app/model/VideoListEntry';
|
||||||
|
import { RequestService } from 'src/app/request.service';
|
||||||
|
import { OnReadListModel } from 'src/app/shared/components/video-list/video-list.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-home',
|
||||||
|
templateUrl: './home.component.html',
|
||||||
|
styleUrls: ['./home.component.scss'],
|
||||||
|
})
|
||||||
|
export class HomeComponent {
|
||||||
|
total: number = 0;
|
||||||
|
videos: VideoListEntry[] = [];
|
||||||
|
|
||||||
|
constructor(public requestService: RequestService) {}
|
||||||
|
|
||||||
|
readList(model: OnReadListModel): void {
|
||||||
|
this.requestService.post(
|
||||||
|
'video-list/read-list',
|
||||||
|
{
|
||||||
|
query: model.query,
|
||||||
|
page: model.page,
|
||||||
|
perPage: model.perPage,
|
||||||
|
},
|
||||||
|
(response: any) => {
|
||||||
|
this.videos = response.items;
|
||||||
|
this.total = response.total;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/app/component/navigation/navigation.component.html
Normal file
15
src/app/component/navigation/navigation.component.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<a routerLink="/home">
|
||||||
|
<h1 class="w-full bg-zinc-700 p-5 text-white text-6xl font-bold text-center">
|
||||||
|
MyTube
|
||||||
|
</h1>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
class="flex flex-wrap items-center justify-center text-xl font-bold bg-zinc-700 text-white"
|
||||||
|
>
|
||||||
|
<li *ngFor="let link of links" class="p-5 me-4">
|
||||||
|
<a [routerLink]="[link.routerLink]">
|
||||||
|
{{ link.caption }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
28
src/app/component/navigation/navigation.component.ts
Normal file
28
src/app/component/navigation/navigation.component.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
interface Link {
|
||||||
|
routerLink: string;
|
||||||
|
caption: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-navigation',
|
||||||
|
templateUrl: './navigation.component.html',
|
||||||
|
styleUrls: ['./navigation.component.scss'],
|
||||||
|
})
|
||||||
|
export class NavigationComponent {
|
||||||
|
links: Link[] = [
|
||||||
|
{
|
||||||
|
routerLink: '/video/upload',
|
||||||
|
caption: 'Upload',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
routerLink: '/video/list',
|
||||||
|
caption: 'Videos',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
routerLink: '/tag/list',
|
||||||
|
caption: 'Tags',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@ -1 +1,11 @@
|
|||||||
<p>list works!</p>
|
<div class="p-5">
|
||||||
|
<div class="mb-5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="block p-1 w-full text-gray-900 text-black dark:text-white bg-gray-50 dark:bg-zinc-700 rounded-lg bg-gray-100 text-base border border-gray-500 dark:border-transparent"
|
||||||
|
(input)="onInputChanged($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<shared-table [items]="tags" [columns]="tagListColumns" />
|
||||||
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,61 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
|
import { Subject, debounceTime } from 'rxjs';
|
||||||
|
import { TagListEntry } from 'src/app/model/TagListEntry';
|
||||||
|
import { RequestService } from 'src/app/request.service';
|
||||||
|
import { ColumnDefinition } from 'src/app/shared/components/table/table.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-list',
|
selector: 'app-list',
|
||||||
templateUrl: './list.component.html',
|
templateUrl: './list.component.html',
|
||||||
styleUrls: ['./list.component.scss']
|
styleUrls: ['./list.component.scss'],
|
||||||
})
|
})
|
||||||
export class ListComponent {
|
export class ListComponent {
|
||||||
|
query$ = new Subject<string>();
|
||||||
|
query: string = '';
|
||||||
|
tags: TagListEntry[] = [];
|
||||||
|
tagListColumns: ColumnDefinition[] = [
|
||||||
|
{
|
||||||
|
header: 'Tag',
|
||||||
|
columnFunction: (item: TagListEntry) => {
|
||||||
|
return item.description;
|
||||||
|
},
|
||||||
|
routerLink: (item: TagListEntry) => {
|
||||||
|
return ['/tag', item.id];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Anzahl Videos',
|
||||||
|
columnFunction: (item: TagListEntry) => {
|
||||||
|
return item.videoCount;
|
||||||
|
},
|
||||||
|
columnCss: 'text-center',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor(public requestService: RequestService) {
|
||||||
|
this.query$.pipe(debounceTime(300)).subscribe((query) => {
|
||||||
|
this.query = query;
|
||||||
|
this.readList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.readList();
|
||||||
|
}
|
||||||
|
|
||||||
|
onInputChanged(event: Event): void {
|
||||||
|
this.query$.next((event.target as HTMLTextAreaElement).value);
|
||||||
|
}
|
||||||
|
|
||||||
|
readList(): void {
|
||||||
|
this.requestService.post(
|
||||||
|
'tag-list/read-list',
|
||||||
|
{
|
||||||
|
query: this.query,
|
||||||
|
},
|
||||||
|
(response: any) => {
|
||||||
|
this.tags = response.items;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1,17 @@
|
|||||||
<p>tag works!</p>
|
<div *ngIf="tagDetails !== null">
|
||||||
|
<h1 class="text-white text-2xl font-bold text-center">
|
||||||
|
{{ tagDetails.description }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<ul class="text-white">
|
||||||
|
<li *ngFor="let alias of tagDetails.aliases">
|
||||||
|
{{ alias.description }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<shared-video-list
|
||||||
|
(onReadList)="readList($event)"
|
||||||
|
[total]="total"
|
||||||
|
[videos]="videos"
|
||||||
|
/>
|
||||||
|
|||||||
@ -1,10 +1,63 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { TagDetails } from 'src/app/model/TagDetails';
|
||||||
|
import { VideoListEntry } from 'src/app/model/VideoListEntry';
|
||||||
|
import { RequestService } from 'src/app/request.service';
|
||||||
|
import { OnReadListModel } from 'src/app/shared/components/video-list/video-list.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-tag',
|
selector: 'app-tag',
|
||||||
templateUrl: './tag.component.html',
|
templateUrl: './tag.component.html',
|
||||||
styleUrls: ['./tag.component.scss']
|
styleUrls: ['./tag.component.scss'],
|
||||||
})
|
})
|
||||||
export class TagComponent {
|
export class TagComponent {
|
||||||
|
tagId: string | undefined;
|
||||||
|
tagDetails: TagDetails | null = null;
|
||||||
|
total: number = 0;
|
||||||
|
videos: VideoListEntry[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
public requestService: RequestService
|
||||||
|
) {
|
||||||
|
this.route.params.subscribe((params) => this.updateTagId(params['id']));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTagId(tagId: string) {
|
||||||
|
this.tagId = tagId;
|
||||||
|
this.readDetails();
|
||||||
|
this.readList({ page: 1, query: '', perPage: 36 });
|
||||||
|
}
|
||||||
|
|
||||||
|
readDetails(): void {
|
||||||
|
if (this.tagId === undefined) return;
|
||||||
|
|
||||||
|
this.requestService.post(
|
||||||
|
'tag/read-details',
|
||||||
|
{
|
||||||
|
tagId: this.tagId,
|
||||||
|
},
|
||||||
|
(response: any) => {
|
||||||
|
this.tagDetails = response;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
readList(model: OnReadListModel): void {
|
||||||
|
if (this.tagId === undefined) return;
|
||||||
|
|
||||||
|
this.requestService.post(
|
||||||
|
'tag/read-video-list',
|
||||||
|
{
|
||||||
|
tagId: this.tagId,
|
||||||
|
query: model.query,
|
||||||
|
page: model.page,
|
||||||
|
perPage: model.perPage,
|
||||||
|
},
|
||||||
|
(response: any) => {
|
||||||
|
this.videos = response.items;
|
||||||
|
this.total = response.total;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,27 +2,16 @@ import { NgModule } from '@angular/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { TagComponent } from './tag.component';
|
import { TagComponent } from './tag.component';
|
||||||
import { ListComponent } from './list/list.component';
|
import { ListComponent } from './list/list.component';
|
||||||
import { VideoListComponent } from './video-list/video-list.component';
|
|
||||||
import { SharedModule } from 'src/app/shared/shared.module';
|
import { SharedModule } from 'src/app/shared/shared.module';
|
||||||
import { RouterModule, Routes } from '@angular/router';
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: ':id/list', component: ListComponent },
|
{ path: 'list', component: ListComponent },
|
||||||
{ path: ':id/video-list', component: VideoListComponent },
|
|
||||||
{ path: ':id', component: TagComponent },
|
{ path: ':id', component: TagComponent },
|
||||||
{ path: '', redirectTo: 'tag/list', pathMatch: 'full'},
|
];
|
||||||
]
|
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [TagComponent, ListComponent],
|
||||||
TagComponent,
|
imports: [CommonModule, SharedModule, RouterModule.forChild(routes)],
|
||||||
ListComponent,
|
|
||||||
VideoListComponent
|
|
||||||
],
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
SharedModule,
|
|
||||||
RouterModule.forChild(routes)
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
export class TagModule { }
|
export class TagModule {}
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
<div class="row">
|
|
||||||
<div class="col-sm-2">
|
|
||||||
<button [routerLink]="['/video/list']">Videos</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-sm-10">
|
|
||||||
<mat-form-field>
|
|
||||||
<input matInput type="text" (input)="onInputChanged($event)" />
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row" *ngIf="tagDetails !== null">
|
|
||||||
<h1>{{tagDetails.description}}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="cardContainer">
|
|
||||||
<mat-card
|
|
||||||
class="mat-card"
|
|
||||||
*ngFor="let video of videos"
|
|
||||||
(click)="selectVideo(video)">
|
|
||||||
<mat-card-header>
|
|
||||||
<mat-card-title class="mat-card-title">{{video.title}}</mat-card-title>
|
|
||||||
<mat-card-subtitle>
|
|
||||||
<a *ngFor="let tag of video.tags"
|
|
||||||
[routerLink]="['/tag', tag.id, 'video-list']">
|
|
||||||
{{tag.description}}
|
|
||||||
</a>
|
|
||||||
</mat-card-subtitle>
|
|
||||||
</mat-card-header>
|
|
||||||
<mat-card-content class="mat-card-content">
|
|
||||||
<img [src]="getThumbnailUrl(video)" alt="Thumbnail">
|
|
||||||
</mat-card-content>
|
|
||||||
</mat-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<mat-paginator [length]="total"
|
|
||||||
[pageSize]="perPage"
|
|
||||||
[pageSizeOptions]="[25, 50, 75]"
|
|
||||||
[hidePageSize]="true"
|
|
||||||
(page)="onPageEvent($event)">
|
|
||||||
</mat-paginator>
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
button {
|
|
||||||
margin: 1em;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
mat-form-field {
|
|
||||||
margin: 1em;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
mat-card {
|
|
||||||
margin: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
mat-card:hover {
|
|
||||||
background-color: lightgray;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-card-title {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardContainer {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-card {
|
|
||||||
flex-basis: calc(100% - 20px);
|
|
||||||
max-width: calc(100% - 20px);
|
|
||||||
flex: 0 0 calc(100% - 20px);
|
|
||||||
margin: 10px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* For tablet: */
|
|
||||||
@media only screen and (min-width: 700px) {
|
|
||||||
.mat-card {
|
|
||||||
flex-basis: calc(50% - 20px);
|
|
||||||
max-width: calc(50% - 20px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* For desktop: */
|
|
||||||
@media only screen and (min-width: 1200px) {
|
|
||||||
.mat-card {
|
|
||||||
max-width: calc(33.33% - 20px);
|
|
||||||
flex-basis: calc(33.33% - 20px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.mat-card .mat-card-content {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-card .mat-card-content img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
import { Component } from '@angular/core';
|
|
||||||
import { PageEvent } from '@angular/material/paginator';
|
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
|
||||||
import { Subject, debounceTime } from 'rxjs';
|
|
||||||
import { TagDetails } from 'src/app/model/TagDetails';
|
|
||||||
import { VideoListEntry } from 'src/app/model/VideoListEntry';
|
|
||||||
import { RequestService } from 'src/app/request.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-video-list',
|
|
||||||
templateUrl: './video-list.component.html',
|
|
||||||
styleUrls: ['./video-list.component.scss']
|
|
||||||
})
|
|
||||||
export class VideoListComponent {
|
|
||||||
tagId: string;
|
|
||||||
tagDetails: TagDetails|null = null;
|
|
||||||
|
|
||||||
query$ = new Subject<string>();
|
|
||||||
query: string = "";
|
|
||||||
total: number = 0;
|
|
||||||
page: number = 1;
|
|
||||||
perPage: number = 25;
|
|
||||||
|
|
||||||
videos: VideoListEntry[] = [];
|
|
||||||
selectedVideoUrl: string|null = null;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private route: ActivatedRoute,
|
|
||||||
private router: Router,
|
|
||||||
public requestService : RequestService,
|
|
||||||
) {
|
|
||||||
this.tagId = this.route.snapshot.paramMap.get('id') ?? '';
|
|
||||||
|
|
||||||
this.query$.pipe(
|
|
||||||
debounceTime(300)
|
|
||||||
).subscribe( query => {
|
|
||||||
this.query = query;
|
|
||||||
this.page = 1;
|
|
||||||
this.readList();
|
|
||||||
} )
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.readDetails();
|
|
||||||
this.readList();
|
|
||||||
}
|
|
||||||
|
|
||||||
onPageEvent(event: PageEvent) {
|
|
||||||
this.page = event.pageIndex + 1;
|
|
||||||
this.perPage = event.pageSize;
|
|
||||||
|
|
||||||
this.readList();
|
|
||||||
}
|
|
||||||
|
|
||||||
onInputChanged(event: Event): void {
|
|
||||||
this.query$.next((event.target as HTMLTextAreaElement).value);
|
|
||||||
}
|
|
||||||
|
|
||||||
selectVideo(entry: VideoListEntry) {
|
|
||||||
this.router.navigate(['/video', entry.id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
readDetails(): void {
|
|
||||||
this.requestService.post(
|
|
||||||
'tag/read-details',
|
|
||||||
{
|
|
||||||
tagId: this.tagId
|
|
||||||
},
|
|
||||||
(response:any) => {
|
|
||||||
this.tagDetails = response;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
readList(): void {
|
|
||||||
this.requestService.post(
|
|
||||||
'tag/read-video-list',
|
|
||||||
{
|
|
||||||
tagId: this.tagId,
|
|
||||||
query: this.query,
|
|
||||||
page: this.page,
|
|
||||||
perPage: this.perPage,
|
|
||||||
},
|
|
||||||
(response:any) => {
|
|
||||||
this.videos = response.items;
|
|
||||||
this.total = response.total;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getThumbnailUrl(entry: VideoListEntry): string {
|
|
||||||
return 'http://wsl-flo/api/video/thumbnail/' + entry.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,38 +1,26 @@
|
|||||||
<div class="row">
|
<div class="p-5">
|
||||||
|
<div class="mb-5 flex flex-row">
|
||||||
<div class="col-sm-10">
|
<input
|
||||||
<mat-form-field>
|
type="text"
|
||||||
<input matInput type="text" (input)="onInputChanged($event)" />
|
class="basis-full p-1 w-full text-gray-900 text-black dark:text-white bg-gray-50 dark:bg-zinc-700 rounded-lg bg-gray-100 text-base border border-gray-500 dark:border-transparent"
|
||||||
</mat-form-field>
|
(input)="onInputChanged($event)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="basis-auto block p-1 w-20 text-gray-900 text-black dark:text-white bg-gray-50 dark:bg-zinc-700 rounded-lg bg-gray-100 text-base border border-gray-500 dark:border-transparent"
|
||||||
|
(change)="readList()"
|
||||||
|
[(ngModel)]="onlyTagless"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-2">
|
|
||||||
<button [routerLink]="['/video/upload']">Upload</button>
|
<shared-table [items]="videos" [columns]="videoListColumns" />
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<shared-paginator
|
||||||
|
(pageChange)="onPageChanged($event)"
|
||||||
|
[page]="page"
|
||||||
|
[perPage]="perPage"
|
||||||
|
[total]="total"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="cardContainer">
|
|
||||||
<mat-card
|
|
||||||
class="mat-card"
|
|
||||||
*ngFor="let video of videos">
|
|
||||||
<mat-card-header>
|
|
||||||
<mat-card-title class="mat-card-title">{{video.title}}</mat-card-title>
|
|
||||||
<mat-card-subtitle>
|
|
||||||
<a *ngFor="let tag of video.tags"
|
|
||||||
[routerLink]="['/tag', tag.id, 'video-list']">
|
|
||||||
{{tag.description}}
|
|
||||||
</a>
|
|
||||||
</mat-card-subtitle>
|
|
||||||
</mat-card-header>
|
|
||||||
<mat-card-content class="mat-card-content">
|
|
||||||
<img [src]="getThumbnailUrl(video)" alt="Thumbnail" (click)="selectVideo(video)">
|
|
||||||
</mat-card-content>
|
|
||||||
</mat-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<mat-paginator [length]="total"
|
|
||||||
[pageSize]="perPage"
|
|
||||||
[pageSizeOptions]="[25, 50, 75]"
|
|
||||||
[hidePageSize]="true"
|
|
||||||
(page)="onPageEvent($event)">
|
|
||||||
</mat-paginator>
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
button {
|
|
||||||
margin: 1em;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
mat-form-field {
|
|
||||||
margin: 1em;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
mat-card {
|
|
||||||
margin: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
mat-card:hover {
|
|
||||||
background-color: lightgray;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-card-title {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardContainer {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-card {
|
|
||||||
flex-basis: calc(100% - 20px);
|
|
||||||
max-width: calc(100% - 20px);
|
|
||||||
flex: 0 0 calc(100% - 20px);
|
|
||||||
margin: 10px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* For tablet: */
|
|
||||||
@media only screen and (min-width: 700px) {
|
|
||||||
.mat-card {
|
|
||||||
flex-basis: calc(50% - 20px);
|
|
||||||
max-width: calc(50% - 20px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* For desktop: */
|
|
||||||
@media only screen and (min-width: 1200px) {
|
|
||||||
.mat-card {
|
|
||||||
max-width: calc(33.33% - 20px);
|
|
||||||
flex-basis: calc(33.33% - 20px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.mat-card .mat-card-content {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-card .mat-card-content img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
@ -1,56 +1,60 @@
|
|||||||
import { Component, Input } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Subject, debounceTime } from 'rxjs';
|
||||||
import { VideoListEntry } from 'src/app/model/VideoListEntry';
|
import { VideoListEntry } from 'src/app/model/VideoListEntry';
|
||||||
import { RequestService } from 'src/app/request.service';
|
import { RequestService } from 'src/app/request.service';
|
||||||
import { Subject } from 'rxjs';
|
import { ColumnDefinition } from 'src/app/shared/components/table/table.component';
|
||||||
import { debounceTime } from 'rxjs/operators';
|
|
||||||
import { PageEvent } from '@angular/material/paginator';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-list',
|
selector: 'app-list',
|
||||||
templateUrl: './list.component.html',
|
templateUrl: './list.component.html',
|
||||||
styleUrls: ['./list.component.scss']
|
styleUrls: ['./list.component.scss'],
|
||||||
})
|
})
|
||||||
export class ListComponent {
|
export class ListComponent {
|
||||||
query$ = new Subject<string>();
|
query$ = new Subject<string>();
|
||||||
query: string = "";
|
query: string = '';
|
||||||
|
onlyTagless: boolean = false;
|
||||||
|
videos: VideoListEntry[] = [];
|
||||||
total: number = 0;
|
total: number = 0;
|
||||||
page: number = 1;
|
page: number = 1;
|
||||||
perPage: number = 25;
|
perPage: number = 180;
|
||||||
|
videoListColumns: ColumnDefinition[] = [
|
||||||
|
{
|
||||||
|
header: 'Titel',
|
||||||
|
columnFunction: (item: VideoListEntry) => {
|
||||||
|
return item.title;
|
||||||
|
},
|
||||||
|
routerLink: (item: VideoListEntry) => {
|
||||||
|
return ['/video', item.id];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Tags',
|
||||||
|
columnFunction: (item: VideoListEntry) => {
|
||||||
|
return item.tags.map((tag) => tag.description);
|
||||||
|
},
|
||||||
|
columnCss: 'text-center',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
videos: VideoListEntry[] = [];
|
constructor(public requestService: RequestService) {
|
||||||
selectedVideoUrl: string|null = null;
|
this.query$.pipe(debounceTime(300)).subscribe((query) => {
|
||||||
|
|
||||||
constructor(
|
|
||||||
private router: Router,
|
|
||||||
public requestService : RequestService,
|
|
||||||
) {
|
|
||||||
this.query$.pipe(
|
|
||||||
debounceTime(300)
|
|
||||||
).subscribe( query => {
|
|
||||||
this.query = query;
|
this.query = query;
|
||||||
this.page = 1;
|
this.page = 1;
|
||||||
this.readList();
|
this.readList();
|
||||||
})
|
});
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.readList();
|
|
||||||
}
|
|
||||||
|
|
||||||
onPageEvent(event: PageEvent) {
|
|
||||||
this.page = event.pageIndex + 1;
|
|
||||||
this.perPage = event.pageSize;
|
|
||||||
|
|
||||||
this.readList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onInputChanged(event: Event): void {
|
onInputChanged(event: Event): void {
|
||||||
this.query$.next((event.target as HTMLTextAreaElement).value);
|
this.query$.next((event.target as HTMLTextAreaElement).value);
|
||||||
}
|
}
|
||||||
|
|
||||||
selectVideo(entry: VideoListEntry) {
|
onPageChanged(newPage: number) {
|
||||||
this.router.navigate(['/video', entry.id]);
|
this.page = newPage;
|
||||||
|
this.readList();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.readList();
|
||||||
}
|
}
|
||||||
|
|
||||||
readList(): void {
|
readList(): void {
|
||||||
@ -60,15 +64,12 @@ export class ListComponent {
|
|||||||
query: this.query,
|
query: this.query,
|
||||||
page: this.page,
|
page: this.page,
|
||||||
perPage: this.perPage,
|
perPage: this.perPage,
|
||||||
|
onlyTagless: this.onlyTagless,
|
||||||
},
|
},
|
||||||
(response:any) => {
|
(response: any) => {
|
||||||
this.videos = response.items;
|
|
||||||
this.total = response.total;
|
this.total = response.total;
|
||||||
|
this.videos = response.items;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getThumbnailUrl(entry: VideoListEntry): string {
|
|
||||||
return 'http://wsl-flo/api/video/thumbnail/' + entry.id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,29 +1,62 @@
|
|||||||
<div class="row">
|
<div class="text-white w-full p-5">
|
||||||
<button [routerLink]="['/video/list']">Liste</button>
|
<input type="file" multiple (change)="onFileSelected($event)" />
|
||||||
<input type="file" multiple (change)="onFileSelected($event)">
|
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="result !== null" class="row">
|
|
||||||
<h1>{{result.total}} Dateien uploaded</h1>
|
|
||||||
<h2>{{result.succeeded}} erfolgreich</h2>
|
|
||||||
<h2>{{result.failed}} fehlgeschlagen</h2>
|
|
||||||
<h3>Details:</h3>
|
|
||||||
|
|
||||||
<table>
|
<div class="p-5">
|
||||||
<tr>
|
<table class="text-white w-full">
|
||||||
<th>Datei</th>
|
<tr class="text-left">
|
||||||
<th>Erfolg</th>
|
<th class="w-4/6">Datei</th>
|
||||||
<th>Fehler</th>
|
<th class="w-1/6">Größe</th>
|
||||||
|
<th class="w-1/6">Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr *ngFor="let details of result.details">
|
<tr *ngFor="let result of uploadResults">
|
||||||
|
<td class="w-4/6">{{ result.file.name }}</td>
|
||||||
<td>{{details.file}}</td>
|
<td class="w-1/6">
|
||||||
<td *ngIf="details.success">Erfolg</td>
|
<div *ngIf="result.file.size < 1024">
|
||||||
<td *ngIf="!details.success">Fehlschlag</td>
|
{{ result.file.size | number : "1.0-0" }} Bytes
|
||||||
<td>
|
</div>
|
||||||
<div *ngIf="!details.success">{{details.error}}</div>
|
<div *ngIf="result.file.size < 1024 * 1024 && result.file.size > 1024">
|
||||||
|
{{ result.file.size / 1024 | number : "1.0-0" }} KB
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
*ngIf="
|
||||||
|
result.file.size < 1024 * 1024 * 1024 &&
|
||||||
|
result.file.size > 1024 * 1024
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ result.file.size / (1024 * 1024) | number : "1.0-0" }} MB
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
*ngIf="
|
||||||
|
result.file.size < 1024 * 1024 * 1024 * 1024 &&
|
||||||
|
result.file.size > 1024 * 1024 * 1024
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ result.file.size / (1024 * 1024 * 1024) | number : "1.0-0" }} GB
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="w-1/6">
|
||||||
|
<div *ngIf="result.status === 'success'" class="font-bold">
|
||||||
|
<a
|
||||||
|
*ngIf="result.result !== undefined"
|
||||||
|
[routerLink]="['/video', result.result.details[0].id]"
|
||||||
|
>Video</a
|
||||||
|
>
|
||||||
|
<div *ngIf="result.result === undefined">success without id</div>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="result.status === 'failed'">
|
||||||
|
<div *ngIf="result.result !== undefined">
|
||||||
|
{{ result.result.details[0].error }}
|
||||||
|
</div>
|
||||||
|
<div *ngIf="result.result === undefined">
|
||||||
|
{{ result.status }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="result.status !== 'success' && result.status !== 'failed'">
|
||||||
|
{{ result.status }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -2,29 +2,51 @@ import { Component } from '@angular/core';
|
|||||||
import { UploadResult } from 'src/app/model/UploadResult';
|
import { UploadResult } from 'src/app/model/UploadResult';
|
||||||
import { RequestService } from 'src/app/request.service';
|
import { RequestService } from 'src/app/request.service';
|
||||||
|
|
||||||
|
interface UploadFileModel {
|
||||||
|
file: File;
|
||||||
|
result: UploadResult | undefined;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-upload',
|
selector: 'app-upload',
|
||||||
templateUrl: './upload.component.html',
|
templateUrl: './upload.component.html',
|
||||||
styleUrls: ['./upload.component.scss']
|
styleUrls: ['./upload.component.scss'],
|
||||||
})
|
})
|
||||||
export class UploadComponent {
|
export class UploadComponent {
|
||||||
|
uploadResults: UploadFileModel[] = [];
|
||||||
|
|
||||||
result: UploadResult|null = null;
|
constructor(private requestService: RequestService) {}
|
||||||
|
|
||||||
constructor(
|
|
||||||
private requestService: RequestService,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
onFileSelected(event: any) {
|
onFileSelected(event: any) {
|
||||||
var files: File[] = event.target.files;
|
var files: File[] = Array.from(event.target.files);
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
var existingItem = this.uploadResults.find(
|
||||||
|
(item) => item.file.name === file.name && item.file.size === file.size
|
||||||
|
);
|
||||||
|
if (existingItem === undefined)
|
||||||
|
this.uploadResults.push({
|
||||||
|
file,
|
||||||
|
result: undefined,
|
||||||
|
status: 'initialized',
|
||||||
|
});
|
||||||
|
else if (existingItem.status === 'failed')
|
||||||
|
existingItem.status = 'initialized';
|
||||||
|
});
|
||||||
|
|
||||||
|
this.uploadResults.forEach(async (item) => {
|
||||||
|
if (item.status === 'initialized') {
|
||||||
|
item.status = 'processing';
|
||||||
this.requestService.postFiles(
|
this.requestService.postFiles(
|
||||||
'video-list/upload',
|
'video-list/upload',
|
||||||
files,
|
[item.file],
|
||||||
(response:any) => {
|
(response: UploadResult) => {
|
||||||
this.result = response;
|
item.result = response;
|
||||||
|
item.status = response.succeeded > 0 ? 'success' : 'failed';
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,113 @@
|
|||||||
<div
|
<div *ngIf="videoDetails !== undefined" class="pt-5 px-5">
|
||||||
*ngIf="videoDetails !== null">
|
<div class="text-3xl text-white font-bold">{{ videoDetails.title }}</div>
|
||||||
|
</div>
|
||||||
<button [routerLink]="['/video/list']">Videos</button>
|
|
||||||
|
<div class="grid grid-cols-1 2xl:grid-cols-4">
|
||||||
<h1>{{videoDetails.title}}</h1>
|
<div class="2xl:col-span-3 p-5">
|
||||||
|
<video
|
||||||
<video
|
#myVideo
|
||||||
style="height: 100%; width: 100%;"
|
controls
|
||||||
controls
|
autoplay
|
||||||
autoplay>
|
(timeupdate)="updateTimestamp()"
|
||||||
<source [src]="videoUrl" type="video/mp4">
|
class="w-full my-2"
|
||||||
</video>
|
>
|
||||||
|
<source [src]="host + 'video/stream/' + this.videoId" type="video/mp4" />
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full 2xl:col-span-1 p-5">
|
||||||
|
<shared-card *ngIf="videoId !== ''" class="w-full" header="Details">
|
||||||
|
<div class="grid grid-cols-1 gap-2 mt-2">
|
||||||
|
<div id="title" class="flex flex-row gap-2">
|
||||||
|
<div class="basis-auto w-24">Titel:</div>
|
||||||
|
<div class="basis-full">
|
||||||
|
<input
|
||||||
|
*ngIf="videoDetails !== undefined"
|
||||||
|
type="text"
|
||||||
|
class="p-1 w-full text-gray-900 text-black dark:text-white bg-gray-50 dark:bg-zinc-700 rounded-lg bg-gray-100 text-base border border-gray-500 dark:border-transparent"
|
||||||
|
[(ngModel)]="videoDetails.title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="duration" class="flex flex-row gap-2">
|
||||||
|
<div class="basis-auto w-24">Länge:</div>
|
||||||
|
<div class="basis-full">
|
||||||
|
<input
|
||||||
|
*ngIf="videoDetails !== undefined"
|
||||||
|
readonly
|
||||||
|
disabled="true"
|
||||||
|
type="text"
|
||||||
|
class="p-1 w-full text-gray-900 text-black dark:text-white bg-gray-50 dark:bg-zinc-700 rounded-lg bg-gray-100 text-base border border-gray-500 dark:border-transparent"
|
||||||
|
[(ngModel)]="videoDetails.duration"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="tags" class="flex flex-row gap-2">
|
||||||
|
<div class="basis-auto w-24">Tags:</div>
|
||||||
|
<div class="basis-full" *ngIf="videoDetails !== undefined">
|
||||||
|
<div class="flex flex-row">
|
||||||
|
<div class="basis-full">
|
||||||
|
<a
|
||||||
|
[routerLink]="['/tag', tag.id]"
|
||||||
|
*ngFor="let tag of videoDetails.tags"
|
||||||
|
><span
|
||||||
|
class="text-sm font-medium me-2 mx-auto px-2.5 py-0.5 rounded bg-zinc-700 hover:bg-zinc-900"
|
||||||
|
>{{ tag.description }}</span
|
||||||
|
></a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="basis-auto">
|
||||||
|
<button (click)="addTag()">
|
||||||
|
<div
|
||||||
|
class="block px-3 py-1.5 rounded rounded-lg bg-zinc-700 hover:bg-zinc-900"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="thumbnails">
|
||||||
|
<img
|
||||||
|
[src]="
|
||||||
|
host +
|
||||||
|
'video/thumbnail/' +
|
||||||
|
this.videoId +
|
||||||
|
'?v=' +
|
||||||
|
this.thumbnailVersion.toString()
|
||||||
|
"
|
||||||
|
alt="Thumbnail"
|
||||||
|
class="w-full h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div id="actions">
|
||||||
|
<div class="flex flex-col md:flex-row gap-2">
|
||||||
|
<div class="basis-0 md:basis-full"></div>
|
||||||
|
<button class="basis-full md:basis-auto" (click)="delete()">
|
||||||
|
<div
|
||||||
|
class="min-w-28 px-3 py-1.5 rounded rounded-lg bg-red-900 hover:bg-red-950"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button class="basis-full md:basis-auto" (click)="setThumbnail()">
|
||||||
|
<div
|
||||||
|
class="min-w-28 px-3 py-1.5 rounded rounded-lg bg-zinc-700 hover:bg-zinc-900"
|
||||||
|
>
|
||||||
|
Thumbnail
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button class="basis-full md:basis-auto" (click)="update()">
|
||||||
|
<div
|
||||||
|
class="min-w-28 px-3 py-1.5 rounded rounded-lg bg-green-700 hover:bg-green-900"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</shared-card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,39 +1,113 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, ElementRef, ViewChild } from '@angular/core';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { TagListEntry } from 'src/app/model/TagListEntry';
|
||||||
import { VideoDetails } from 'src/app/model/VideoDetails';
|
import { VideoDetails } from 'src/app/model/VideoDetails';
|
||||||
import { RequestService } from 'src/app/request.service';
|
import { RequestService } from 'src/app/request.service';
|
||||||
|
import { TagSelectorComponent } from 'src/app/shared/components/tag-selector/tag-selector.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-video',
|
selector: 'app-video',
|
||||||
templateUrl: './video.component.html',
|
templateUrl: './video.component.html',
|
||||||
styleUrls: ['./video.component.scss']
|
styleUrls: ['./video.component.scss'],
|
||||||
})
|
})
|
||||||
export class VideoComponent {
|
export class VideoComponent {
|
||||||
|
@ViewChild('myVideo') videoElement: ElementRef | undefined;
|
||||||
|
|
||||||
|
host: string;
|
||||||
videoId: string;
|
videoId: string;
|
||||||
videoUrl: string|null = null;
|
thumbnailVersion: number;
|
||||||
videoDetails: VideoDetails|null = null;
|
videoDetails: VideoDetails | undefined;
|
||||||
|
currentTimestamp: number | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
public router: Router,
|
||||||
private requestService : RequestService,
|
public route: ActivatedRoute,
|
||||||
|
public requestService: RequestService,
|
||||||
|
public dialog: MatDialog
|
||||||
) {
|
) {
|
||||||
this.videoId = this.route.snapshot.paramMap.get('id') ?? '';
|
this.videoId = this.route.snapshot.paramMap.get('id') ?? '';
|
||||||
this.videoUrl = "http://wsl-flo/api/video/stream/" + this.videoId;
|
let hostString = window.location.host;
|
||||||
|
let protocol = window.location.protocol;
|
||||||
|
this.host = protocol + '//' + hostString + '/api/';
|
||||||
|
this.thumbnailVersion = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.readDetails();
|
this.readDetails();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateTimestamp(): void {
|
||||||
|
if (this.videoElement === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const video: HTMLVideoElement = this.videoElement.nativeElement;
|
||||||
|
this.currentTimestamp = video.currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
readDetails(): void {
|
readDetails(): void {
|
||||||
this.requestService.post(
|
this.requestService.post(
|
||||||
'video/read-details',
|
'video/read-details',
|
||||||
{
|
{
|
||||||
videoId: this.videoId
|
videoId: this.videoId,
|
||||||
},
|
},
|
||||||
(response:any) => {
|
(response: any) => {
|
||||||
this.videoDetails = response;
|
this.videoDetails = response;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setThumbnail(): void {
|
||||||
|
this.requestService.post(
|
||||||
|
'video/set-thumbnail',
|
||||||
|
{
|
||||||
|
videoId: this.videoId,
|
||||||
|
timestamp: this.currentTimestamp,
|
||||||
|
},
|
||||||
|
(response: any) => {
|
||||||
|
this.thumbnailVersion++;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
addTag(): void {
|
||||||
|
let dialogRef = this.dialog.open(TagSelectorComponent, {
|
||||||
|
panelClass: 'bg-zinc-900',
|
||||||
|
maxHeight: '400px',
|
||||||
|
width: '600px',
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe((result) => {
|
||||||
|
console.log(result);
|
||||||
|
|
||||||
|
if (result === undefined) return;
|
||||||
|
this.videoDetails?.tags.push(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
update(): void {
|
||||||
|
this.requestService.post(
|
||||||
|
'video/update-details',
|
||||||
|
{
|
||||||
|
videoId: this.videoId,
|
||||||
|
title: this.videoDetails?.title,
|
||||||
|
tagIds: this.videoDetails?.tags.map((item) => item.id),
|
||||||
|
},
|
||||||
|
(response: any) => {
|
||||||
|
this.readDetails();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(): void {
|
||||||
|
this.requestService.post(
|
||||||
|
'video/delete',
|
||||||
|
{
|
||||||
|
videoId: this.videoId,
|
||||||
|
},
|
||||||
|
(response: any) => {
|
||||||
|
this.router.navigate(['/video/list']);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,28 +1,19 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ListComponent } from './list/list.component';
|
|
||||||
import { VideoComponent } from './video.component';
|
import { VideoComponent } from './video.component';
|
||||||
import { SharedModule } from 'src/app/shared/shared.module';
|
import { SharedModule } from 'src/app/shared/shared.module';
|
||||||
import { RouterModule, Routes } from '@angular/router';
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
import { UploadComponent } from './upload/upload.component';
|
import { UploadComponent } from './upload/upload.component';
|
||||||
|
import { ListComponent } from './list/list.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: 'list', component: ListComponent },
|
|
||||||
{ path: 'upload', component: UploadComponent },
|
{ path: 'upload', component: UploadComponent },
|
||||||
|
{ path: 'list', component: ListComponent },
|
||||||
{ path: ':id', component: VideoComponent },
|
{ path: ':id', component: VideoComponent },
|
||||||
{ path: '', redirectTo: 'video/list', pathMatch: 'full'},
|
];
|
||||||
]
|
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [VideoComponent, UploadComponent, ListComponent],
|
||||||
ListComponent,
|
imports: [CommonModule, SharedModule, RouterModule.forChild(routes)],
|
||||||
VideoComponent,
|
|
||||||
UploadComponent
|
|
||||||
],
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
SharedModule,
|
|
||||||
RouterModule.forChild(routes)
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
export class VideoModule { }
|
export class VideoModule {}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
export interface TagListEntry {
|
export interface TagListEntry {
|
||||||
id: string;
|
id: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
videoCount?: number;
|
||||||
}
|
}
|
||||||
@ -1,5 +1,8 @@
|
|||||||
|
import { TagListEntry } from "./TagListEntry";
|
||||||
|
|
||||||
export interface VideoDetails {
|
export interface VideoDetails {
|
||||||
title: string;
|
title: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
duration: string;
|
||||||
|
tags: TagListEntry[];
|
||||||
}
|
}
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import { TagListEntry } from "./TagListEntry";
|
import { TagListEntry } from './TagListEntry';
|
||||||
|
|
||||||
export interface VideoListEntry {
|
export interface VideoListEntry {
|
||||||
title: string;
|
title: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
duration: string;
|
||||||
tags: TagListEntry[];
|
tags: TagListEntry[];
|
||||||
}
|
}
|
||||||
31
src/app/shared/components/card/card.component.html
Normal file
31
src/app/shared/components/card/card.component.html
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<div
|
||||||
|
id="card"
|
||||||
|
class="grow p-3 my-2 rounded-xl shadow-lg border border-gray-100 dark:bg-zinc-800 dark:border-zinc-900"
|
||||||
|
>
|
||||||
|
<div id="header">
|
||||||
|
<div class="flex flex-row">
|
||||||
|
<div *ngIf="icon !== null">
|
||||||
|
<img class="h-6 w-6 m-2" [src]="icon" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
*ngIf="header !== null"
|
||||||
|
class="my-auto text-xl font-medium text-black dark:text-white truncate"
|
||||||
|
>
|
||||||
|
{{ header }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
*ngIf="subHeader !== null"
|
||||||
|
class="text-slate-500 dark:text-slate-400 mx-2"
|
||||||
|
>
|
||||||
|
{{ subHeader }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="content" class="mx-5 text-black dark:text-white">
|
||||||
|
<div *ngIf="content !== null" class="my-2 italic">"{{ content }}"</div>
|
||||||
|
<div *ngIf="content === null">
|
||||||
|
<ng-content></ng-content>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
0
src/app/shared/components/card/card.component.scss
Normal file
0
src/app/shared/components/card/card.component.scss
Normal file
13
src/app/shared/components/card/card.component.ts
Normal file
13
src/app/shared/components/card/card.component.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'shared-card',
|
||||||
|
templateUrl: './card.component.html',
|
||||||
|
styleUrls: ['./card.component.scss'],
|
||||||
|
})
|
||||||
|
export class CardComponent {
|
||||||
|
@Input() header: string | null = null;
|
||||||
|
@Input() icon: string | null = null;
|
||||||
|
@Input() subHeader: string | null = null;
|
||||||
|
@Input() content: string | null = null;
|
||||||
|
}
|
||||||
21
src/app/shared/components/form/form.component.html
Normal file
21
src/app/shared/components/form/form.component.html
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<div
|
||||||
|
*ngIf="caption !== null"
|
||||||
|
class="text-lg font-bold mb-3 text-black dark:text-white"
|
||||||
|
>
|
||||||
|
{{ caption }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col md:space-x-4">
|
||||||
|
<div id="form-body" class="flex-1 basis-full">
|
||||||
|
<ng-content />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row">
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button
|
||||||
|
(click)="submit.emit()"
|
||||||
|
class="flex-0 p-2 min-w-24 bg-green-700 hover:bg-green-800 dark:bg-green-800 dark:hover:bg-green-900 rounded-lg"
|
||||||
|
>
|
||||||
|
<p class="text-lg text-white text-md font-bold">Submit</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
0
src/app/shared/components/form/form.component.scss
Normal file
0
src/app/shared/components/form/form.component.scss
Normal file
12
src/app/shared/components/form/form.component.ts
Normal file
12
src/app/shared/components/form/form.component.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'shared-form',
|
||||||
|
templateUrl: './form.component.html',
|
||||||
|
styleUrls: ['./form.component.scss']
|
||||||
|
})
|
||||||
|
export class FormComponent {
|
||||||
|
@Output() submit = new EventEmitter<any>();
|
||||||
|
@Input() caption: string|null = null;
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
<div class="my-5 lg:m-5">
|
||||||
|
<div
|
||||||
|
*ngIf="header !== null"
|
||||||
|
class="text-xl font-bold">
|
||||||
|
{{header}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img
|
||||||
|
class="rounded-md w-full"
|
||||||
|
[src]="source"
|
||||||
|
[alt]="alt" />
|
||||||
|
|
||||||
|
</div>
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'shared-image-presenter',
|
||||||
|
templateUrl: './image-presenter.component.html',
|
||||||
|
styleUrls: ['./image-presenter.component.scss']
|
||||||
|
})
|
||||||
|
export class ImagePresenterComponent {
|
||||||
|
@Input() header: string|null = null;
|
||||||
|
@Input() alt: string|null = null;
|
||||||
|
@Input() source = '';
|
||||||
|
}
|
||||||
69
src/app/shared/components/paginator/paginator.component.html
Normal file
69
src/app/shared/components/paginator/paginator.component.html
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<nav aria-label="Page navigation example">
|
||||||
|
<ul class="flex items-center -space-x-px h-8 text-sm">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
(click)="setActivePage(-1)"
|
||||||
|
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 dark:text-white bg-white dark:bg-zinc-800 border border-e-0 border-gray-500 dark:border-zinc-800 rounded-s-lg hover:bg-gray-100 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Previous</span>
|
||||||
|
<svg
|
||||||
|
class="w-2.5 h-2.5 rtl:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 6 10"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 1 1 5l4 4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li *ngFor="let item of items" class="w-full">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center h-8 leading-tight border border-gray-500 dark:border-zinc-800"
|
||||||
|
[ngClass]="{
|
||||||
|
'bg-green-700 dark:bg-green-800 text-white': item.page === page,
|
||||||
|
'bg-white dark:bg-zinc-700 text-black dark:text-white':
|
||||||
|
item.page !== page
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div *ngIf="item.page === null" class="px-3">...</div>
|
||||||
|
<button
|
||||||
|
class="px-3 w-full h-full hover:bg-gray-100 hover:text-gray-700"
|
||||||
|
*ngIf="item.page !== null"
|
||||||
|
(click)="setActivePage(item.page)"
|
||||||
|
>
|
||||||
|
{{ item.page }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
(click)="setActivePage(-2)"
|
||||||
|
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 dark:text-white bg-white dark:bg-zinc-800 border border-gray-500 dark:border-zinc-800 rounded-e-lg hover:bg-gray-100 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Next</span>
|
||||||
|
<svg
|
||||||
|
class="w-2.5 h-2.5 rtl:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 6 10"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="m1 9 4-4-4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
80
src/app/shared/components/paginator/paginator.component.ts
Normal file
80
src/app/shared/components/paginator/paginator.component.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
OnChanges,
|
||||||
|
Output,
|
||||||
|
SimpleChanges,
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
|
export interface PaginatorItem {
|
||||||
|
page: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'shared-paginator',
|
||||||
|
templateUrl: './paginator.component.html',
|
||||||
|
styleUrls: ['./paginator.component.scss'],
|
||||||
|
})
|
||||||
|
export class PaginatorComponent implements OnChanges {
|
||||||
|
@Output() pageChange = new EventEmitter<number>();
|
||||||
|
@Input() page: number = 1;
|
||||||
|
@Input() perPage: number = 10;
|
||||||
|
@Input() total: number | null = null;
|
||||||
|
items: PaginatorItem[] = [];
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
this.calculateItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected setActivePage(newPage: number | null) {
|
||||||
|
if (newPage === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPage === -1) {
|
||||||
|
newPage = this.page - 1;
|
||||||
|
}
|
||||||
|
if (newPage === -2) {
|
||||||
|
newPage = this.page + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPage <= 0 || newPage > this.getMaxPages()) return;
|
||||||
|
|
||||||
|
this.page = newPage;
|
||||||
|
this.pageChange.emit(newPage);
|
||||||
|
this.calculateItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateItems(): void {
|
||||||
|
this.items = [];
|
||||||
|
|
||||||
|
let first = 1;
|
||||||
|
let last = this.getMaxPages();
|
||||||
|
let rangeFirst = this.page - 1;
|
||||||
|
let rangeLast = this.page + 1;
|
||||||
|
|
||||||
|
if (this.page === 1) {
|
||||||
|
rangeFirst = 1;
|
||||||
|
rangeLast = last < 3 ? last : 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.page === last) {
|
||||||
|
rangeFirst = Math.max(1, last - 2);
|
||||||
|
rangeLast = last;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rangeFirst > 1) this.items.push({ page: first });
|
||||||
|
if (rangeFirst > 2) this.items.push({ page: null });
|
||||||
|
|
||||||
|
for (let index = rangeFirst; index <= rangeLast; index++) {
|
||||||
|
this.items.push({ page: index });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rangeLast < last - 1) this.items.push({ page: null });
|
||||||
|
if (rangeLast < last) this.items.push({ page: last });
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMaxPages(): number {
|
||||||
|
return Math.ceil((this.total ?? 0) / this.perPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
<div class="text-sm font-medium text-center text-gray-500">
|
||||||
|
<ul class="flex flex-wrap -mb-px">
|
||||||
|
<li *ngFor="let tab of tabs; index as currentIndex" class="grow lg:grow-0">
|
||||||
|
<button
|
||||||
|
(click)="setActiveTab(tab)"
|
||||||
|
class="w-full lg:w-auto text-xl font-bold inline-block px-4 pt-4 pb-3 border-b-2 rounded-t-lg hover:text-green-700 hover:border-green-700 hover:no-underline dark:hover:text-green-800 dark:hover:border-green-800"
|
||||||
|
[ngClass]="{
|
||||||
|
'text-black border-black dark:text-green-800 dark:border-green-800 hover:text-green-700 dark:hover:border-white dark:hover:text-white':
|
||||||
|
currentIndex === activeIndex
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ tab.title }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
<ng-content></ng-content>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
import { Component, ContentChildren, EventEmitter, Input, Output, QueryList } from '@angular/core';
|
||||||
|
import { TabItem } from '../../models/TabItem';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'shared-tab-control',
|
||||||
|
templateUrl: './tab-control.component.html',
|
||||||
|
styleUrls: ['./tab-control.component.scss']
|
||||||
|
})
|
||||||
|
export class TabControlComponent {
|
||||||
|
@Input() public tabs: TabItem[] = [];
|
||||||
|
@Input() public activeTabId: string = '';
|
||||||
|
@Output() public activeTabIdChange = new EventEmitter<string>();
|
||||||
|
|
||||||
|
@ContentChildren('tabContent') tabContents!: QueryList<any>;
|
||||||
|
|
||||||
|
public activeIndex = 0;
|
||||||
|
|
||||||
|
public ngOnChanges() {
|
||||||
|
this.setActiveTab(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setActiveTab(tab: TabItem|null) {
|
||||||
|
if (tab !== null) {
|
||||||
|
this.activeTabId = tab.id;
|
||||||
|
this.activeTabIdChange.emit(this.activeTabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeIndex = this.tabs.findIndex((i) => i.id === this.activeTabId);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/app/shared/components/table/table.component.html
Normal file
54
src/app/shared/components/table/table.component.html
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<table class="w-full" [ngClass]="[background, foreground, border]">
|
||||||
|
<thead class="uppercase border-b text-sm" [ngClass]="[border]">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
*ngFor="let column of columns; index as currentIndex"
|
||||||
|
[class]="'px-5 py-2 ' + column.headerCss"
|
||||||
|
[ngClass]="{
|
||||||
|
backgroundColumn: currentIndex % 2 === 0,
|
||||||
|
backgroundColumnAlternate: currentIndex % 2 === 1
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div [innerHtml]="column.header"></div>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let item of items" class="border-b" [ngClass]="[border]">
|
||||||
|
<td
|
||||||
|
*ngFor="let column of columns; index as currentIndex"
|
||||||
|
[class]="'px-5 py-2 ' + column.columnCss"
|
||||||
|
[ngClass]="{
|
||||||
|
backgroundColumn: currentIndex % 2 === 0,
|
||||||
|
backgroundColumnAlternate: currentIndex % 2 === 1
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div *ngIf="column.routerLink !== undefined">
|
||||||
|
<a [routerLink]="column.routerLink(item)">
|
||||||
|
<div *ngIf="column.columnContent !== undefined">
|
||||||
|
{{ column.columnContent }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
*ngIf="column.columnFunction !== undefined"
|
||||||
|
[innerHtml]="column.columnFunction(item)"
|
||||||
|
></div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="column.routerLink === undefined">
|
||||||
|
<div *ngIf="column.columnContent !== undefined">
|
||||||
|
<div *ngIf="isBoolean(column.columnContent)">
|
||||||
|
<input type="checkbox" [(ngModel)]="column.columnContent" />
|
||||||
|
</div>
|
||||||
|
<div *ngIf="!isBoolean(column.columnContent)">
|
||||||
|
{{ column.columnContent }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
*ngIf="column.columnFunction !== undefined"
|
||||||
|
[innerHtml]="column.columnFunction(item)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
31
src/app/shared/components/table/table.component.ts
Normal file
31
src/app/shared/components/table/table.component.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
|
||||||
|
export interface ColumnDefinition {
|
||||||
|
header: string;
|
||||||
|
columnContent?: string | undefined;
|
||||||
|
columnFunction?: Function | undefined;
|
||||||
|
routerLink?: Function | undefined;
|
||||||
|
headerCss?: string | undefined;
|
||||||
|
columnCss?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'shared-table',
|
||||||
|
templateUrl: './table.component.html',
|
||||||
|
styleUrls: ['./table.component.scss'],
|
||||||
|
})
|
||||||
|
export class TableComponent {
|
||||||
|
@Input() foreground: string = 'text-white';
|
||||||
|
@Input() background: string = 'bg-zinc-800';
|
||||||
|
@Input() backgroundColumn: string = 'bg-zinc-800';
|
||||||
|
@Input() backgroundColumnAlternate: string = 'bg-zinc-700';
|
||||||
|
@Input() border: string = 'border-zinc-600';
|
||||||
|
|
||||||
|
@Input() items: any[] = [];
|
||||||
|
@Input() columns: ColumnDefinition[] = [];
|
||||||
|
|
||||||
|
isBoolean(obje: any): boolean {
|
||||||
|
console.log(obje);
|
||||||
|
return typeof obje === 'boolean';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
<div class="p-5 bg-zinc-700">
|
||||||
|
<div class="mb-5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="block p-1 w-full text-black dark:text-white bg-gray-50 dark:bg-zinc-700 rounded-lg bg-gray-100 text-base border border-gray-500 dark:border-transparent"
|
||||||
|
(input)="onInputChanged($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1
|
||||||
|
*ngFor="let tag of tags"
|
||||||
|
(click)="onClickTag(tag)"
|
||||||
|
class="text-white text-bold"
|
||||||
|
>
|
||||||
|
{{ tag.description }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
import { Component, Inject } from '@angular/core';
|
||||||
|
import { ColumnDefinition } from '../table/table.component';
|
||||||
|
import { Subject, debounceTime } from 'rxjs';
|
||||||
|
import { TagListEntry } from 'src/app/model/TagListEntry';
|
||||||
|
import { RequestService } from 'src/app/request.service';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'shared-tag-selector',
|
||||||
|
templateUrl: './tag-selector.component.html',
|
||||||
|
styleUrls: ['./tag-selector.component.scss'],
|
||||||
|
})
|
||||||
|
export class TagSelectorComponent {
|
||||||
|
query$ = new Subject<string>();
|
||||||
|
query: string = '';
|
||||||
|
tags: TagListEntry[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public requestService: RequestService,
|
||||||
|
public dialogRef: MatDialogRef<TagSelectorComponent>
|
||||||
|
) {
|
||||||
|
this.query$.pipe(debounceTime(300)).subscribe((query) => {
|
||||||
|
this.query = query;
|
||||||
|
this.readList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.readList();
|
||||||
|
}
|
||||||
|
|
||||||
|
onInputChanged(event: Event): void {
|
||||||
|
this.query$.next((event.target as HTMLTextAreaElement).value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickTag(tag: TagListEntry) {
|
||||||
|
this.dialogRef.close(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
this.dialogRef.close(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
readList(): void {
|
||||||
|
this.requestService.post(
|
||||||
|
'tag-list/read-list',
|
||||||
|
{
|
||||||
|
query: this.query,
|
||||||
|
},
|
||||||
|
(response: any) => {
|
||||||
|
this.tags = response.items;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
<div class="p-5 mt-5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="block p-1 w-full text-gray-900 text-black dark:text-white bg-gray-50 dark:bg-zinc-700 rounded-lg bg-gray-100 text-base border border-gray-500 dark:border-transparent"
|
||||||
|
(input)="onInputChanged($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="card-container"
|
||||||
|
class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 px-5"
|
||||||
|
>
|
||||||
|
<shared-card
|
||||||
|
*ngFor="let video of videos; index as currentIndex"
|
||||||
|
class="w-full"
|
||||||
|
[header]="video.title"
|
||||||
|
[subHeader]="video.duration"
|
||||||
|
>
|
||||||
|
<div class="h-8 my-auto">
|
||||||
|
<a [routerLink]="['/tag', tag.id]" *ngFor="let tag of video.tags"
|
||||||
|
><span
|
||||||
|
class="text-sm font-medium me-2 px-2.5 py-0.5 rounded bg-zinc-700 hover:bg-zinc-900"
|
||||||
|
>{{ tag.description }}</span
|
||||||
|
></a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img
|
||||||
|
[src]="getThumbnailUrl(video)"
|
||||||
|
alt="Thumbnail"
|
||||||
|
(click)="selectVideo(video)"
|
||||||
|
class="m-auto h-80"
|
||||||
|
/>
|
||||||
|
</shared-card>
|
||||||
|
</div>
|
||||||
|
<shared-paginator
|
||||||
|
(pageChange)="onPageChanged($event)"
|
||||||
|
[page]="page"
|
||||||
|
[perPage]="perPage"
|
||||||
|
[total]="total"
|
||||||
|
>
|
||||||
|
</shared-paginator>
|
||||||
67
src/app/shared/components/video-list/video-list.component.ts
Normal file
67
src/app/shared/components/video-list/video-list.component.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { Subject, debounceTime } from 'rxjs';
|
||||||
|
import { VideoListEntry } from 'src/app/model/VideoListEntry';
|
||||||
|
import { RequestService } from 'src/app/request.service';
|
||||||
|
|
||||||
|
export interface OnReadListModel {
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'shared-video-list',
|
||||||
|
templateUrl: './video-list.component.html',
|
||||||
|
styleUrls: ['./video-list.component.scss'],
|
||||||
|
})
|
||||||
|
export class VideoListComponent {
|
||||||
|
query$ = new Subject<string>();
|
||||||
|
query: string = '';
|
||||||
|
@Input() total: number = 0;
|
||||||
|
@Input() page: number = 1;
|
||||||
|
@Input() perPage: number = 36;
|
||||||
|
@Input() videos: VideoListEntry[] = [];
|
||||||
|
@Output() onReadList: EventEmitter<OnReadListModel> = new EventEmitter();
|
||||||
|
|
||||||
|
constructor(private router: Router, public requestService: RequestService) {
|
||||||
|
this.query$.pipe(debounceTime(300)).subscribe((query) => {
|
||||||
|
this.query = query;
|
||||||
|
this.page = 1;
|
||||||
|
this.readList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.readList();
|
||||||
|
}
|
||||||
|
|
||||||
|
onPageChanged(newPage: number) {
|
||||||
|
this.page = newPage;
|
||||||
|
|
||||||
|
this.readList();
|
||||||
|
}
|
||||||
|
|
||||||
|
onInputChanged(event: Event): void {
|
||||||
|
this.query$.next((event.target as HTMLTextAreaElement).value);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectVideo(entry: VideoListEntry) {
|
||||||
|
this.router.navigate(['/video', entry.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
readList(): void {
|
||||||
|
this.onReadList.emit({
|
||||||
|
query: this.query,
|
||||||
|
page: this.page,
|
||||||
|
perPage: this.perPage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getThumbnailUrl(entry: VideoListEntry): string {
|
||||||
|
let hostString = window.location.host;
|
||||||
|
let protocol = window.location.protocol;
|
||||||
|
let host = protocol + '//' + hostString + '/api/';
|
||||||
|
return host + 'video/thumbnail/' + entry.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
<div class="my-5 lg:m-5">
|
||||||
|
<div
|
||||||
|
*ngIf="header !== null"
|
||||||
|
class="text-xl font-bold">
|
||||||
|
{{header}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<video
|
||||||
|
autoplay
|
||||||
|
controls
|
||||||
|
loop
|
||||||
|
class="rounded-md w-full">
|
||||||
|
<source [src]="source" type="video/mp4" />
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'shared-video-presenter',
|
||||||
|
templateUrl: './video-presenter.component.html',
|
||||||
|
styleUrls: ['./video-presenter.component.scss']
|
||||||
|
})
|
||||||
|
export class VideoPresenterComponent {
|
||||||
|
@Input() header: string|null = null;
|
||||||
|
@Input() source = '';
|
||||||
|
}
|
||||||
4
src/app/shared/models/TabItem.ts
Normal file
4
src/app/shared/models/TabItem.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface TabItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
@ -1,6 +1,13 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormComponent } from './components/form/form.component';
|
||||||
|
import { CardComponent } from './components/card/card.component';
|
||||||
|
import { PaginatorComponent } from './components/paginator/paginator.component';
|
||||||
|
import { TabControlComponent } from './components/tab-control/tab-control.component';
|
||||||
|
import { VideoPresenterComponent } from './components/video-presenter/video-presenter.component';
|
||||||
|
import { ImagePresenterComponent } from './components/image-presenter/image-presenter.component';
|
||||||
|
import { TableComponent } from './components/table/table.component';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
@ -9,7 +16,7 @@ import { MatToolbarModule } from '@angular/material/toolbar';
|
|||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { MatListModule } from '@angular/material/list';
|
import { MatListModule } from '@angular/material/list';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatGridListModule } from '@angular/material/grid-list'
|
import { MatGridListModule } from '@angular/material/grid-list';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { MatExpansionModule } from '@angular/material/expansion';
|
import { MatExpansionModule } from '@angular/material/expansion';
|
||||||
@ -22,14 +29,58 @@ import { MatCheckboxModule } from '@angular/material/checkbox';
|
|||||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||||
import { MatTabsModule } from '@angular/material/tabs';
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
import { MatTableModule } from '@angular/material/table';
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
|
import { VideoListComponent } from './components/video-list/video-list.component';
|
||||||
|
import { TagSelectorComponent } from './components/tag-selector/tag-selector.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
|
ImagePresenterComponent,
|
||||||
|
VideoPresenterComponent,
|
||||||
|
CardComponent,
|
||||||
|
TabControlComponent,
|
||||||
|
PaginatorComponent,
|
||||||
|
FormComponent,
|
||||||
|
TableComponent,
|
||||||
|
VideoListComponent,
|
||||||
|
TagSelectorComponent,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
ImagePresenterComponent,
|
||||||
|
VideoPresenterComponent,
|
||||||
|
CardComponent,
|
||||||
|
TabControlComponent,
|
||||||
|
PaginatorComponent,
|
||||||
|
FormComponent,
|
||||||
|
TableComponent,
|
||||||
|
VideoListComponent,
|
||||||
|
TagSelectorComponent,
|
||||||
|
|
||||||
|
MatSlideToggleModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatGridListModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
FormsModule,
|
||||||
|
MatExpansionModule,
|
||||||
|
MatMenuModule,
|
||||||
|
MatListModule,
|
||||||
|
MatToolbarModule,
|
||||||
|
MatSidenavModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatDividerModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatTabsModule,
|
||||||
|
MatTableModule,
|
||||||
|
MatChipsModule,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
|
RouterModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
MatSlideToggleModule,
|
MatSlideToggleModule,
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
@ -51,32 +102,8 @@ import { MatTableModule } from '@angular/material/table';
|
|||||||
MatPaginatorModule,
|
MatPaginatorModule,
|
||||||
MatCheckboxModule,
|
MatCheckboxModule,
|
||||||
MatTabsModule,
|
MatTabsModule,
|
||||||
MatTableModule
|
MatTableModule,
|
||||||
|
MatChipsModule,
|
||||||
],
|
],
|
||||||
exports: [
|
|
||||||
// to be replaced
|
|
||||||
MatSlideToggleModule,
|
|
||||||
MatCardModule,
|
|
||||||
MatButtonModule,
|
|
||||||
MatIconModule,
|
|
||||||
MatGridListModule,
|
|
||||||
MatFormFieldModule,
|
|
||||||
FormsModule,
|
|
||||||
MatExpansionModule,
|
|
||||||
MatMenuModule,
|
|
||||||
MatListModule,
|
|
||||||
MatToolbarModule,
|
|
||||||
MatSidenavModule,
|
|
||||||
MatInputModule,
|
|
||||||
MatSelectModule,
|
|
||||||
MatDividerModule,
|
|
||||||
MatDialogModule,
|
|
||||||
MatSnackBarModule,
|
|
||||||
MatPaginatorModule,
|
|
||||||
MatCheckboxModule,
|
|
||||||
MatTabsModule,
|
|
||||||
MatTableModule
|
|
||||||
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
export class SharedModule { }
|
export class SharedModule {}
|
||||||
|
|||||||
@ -1,17 +1,15 @@
|
|||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8" />
|
||||||
<title>mytube</title>
|
<title>MyTube</title>
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/x-icon" href="assets/weed.svg" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.3.0/flowbite.min.js"></script>
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
</head>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
|
<body class="bg-zinc-900">
|
||||||
</head>
|
|
||||||
<body class="mat-typography mat-app-background">
|
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,65 +1,3 @@
|
|||||||
body {
|
@tailwind base;
|
||||||
margin: 0;
|
@tailwind components;
|
||||||
height: 100vh;
|
@tailwind utilities;
|
||||||
}
|
|
||||||
|
|
||||||
app-root {
|
|
||||||
display:inline-block;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-container {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-1 {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.flex-2 {
|
|
||||||
flex: 2;
|
|
||||||
}
|
|
||||||
.flex-3 {
|
|
||||||
flex: 3;
|
|
||||||
}
|
|
||||||
.flex-4 {
|
|
||||||
flex: 4;
|
|
||||||
}
|
|
||||||
.flex-5 {
|
|
||||||
flex: 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
padding: 0;
|
|
||||||
max-width: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.svg {
|
|
||||||
filter: invert(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.svg-1 {
|
|
||||||
filter: invert(1);
|
|
||||||
height: 1em;
|
|
||||||
width: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svg-2 {
|
|
||||||
filter: invert(1);
|
|
||||||
height: 2em;
|
|
||||||
width: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svg-3 {
|
|
||||||
filter: invert(1);
|
|
||||||
height: 3em;
|
|
||||||
width: 3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svg-4 {
|
|
||||||
filter: invert(1);
|
|
||||||
height: 4em;
|
|
||||||
width: 4em;
|
|
||||||
}
|
|
||||||
9
tailwind.config.js
Normal file
9
tailwind.config.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ["./src/**/*.{html,ts}"],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user