Rework sidebar

This commit is contained in:
Geomitron
2023-12-20 18:20:27 -06:00
parent 742c6a28d0
commit 0a83ea3937
16 changed files with 846 additions and 150 deletions

View File

@@ -7,6 +7,7 @@ import { AppRoutingModule } from './app-routing.module'
import { AppComponent } from './app.component'
import { BrowseComponent } from './components/browse/browse.component'
import { ChartSidebarInstrumentComponent } from './components/browse/chart-sidebar/chart-sidebar-instrument/chart-sidebar-instrument.component'
import { ChartSidebarMenutComponent } from './components/browse/chart-sidebar/chart-sidebar-menu/chart-sidebar-menu.component'
import { ChartSidebarComponent } from './components/browse/chart-sidebar/chart-sidebar.component'
import { ResultTableRowComponent } from './components/browse/result-table/result-table-row/result-table-row.component'
import { ResultTableComponent } from './components/browse/result-table/result-table.component'
@@ -17,6 +18,7 @@ import { SettingsComponent } from './components/settings/settings.component'
import { ToolbarComponent } from './components/toolbar/toolbar.component'
import { CheckboxDirective } from './core/directives/checkbox.directive'
import { ProgressBarDirective } from './core/directives/progress-bar.directive'
import { RemoveStyleTagsPipe } from './core/pipes/remove-style-tags.pipe'
@NgModule({
declarations: [
@@ -28,10 +30,12 @@ import { ProgressBarDirective } from './core/directives/progress-bar.directive'
ResultTableComponent,
ChartSidebarComponent,
ChartSidebarInstrumentComponent,
ChartSidebarMenutComponent,
ResultTableRowComponent,
DownloadsModalComponent,
ProgressBarDirective,
CheckboxDirective,
RemoveStyleTagsPipe,
SettingsComponent,
],
imports: [

View File

@@ -2,10 +2,10 @@
<div class="flex flex-1 overflow-y-hidden">
<!-- TODO: adjust sizing -->
<div
class="flex-grow-[1] overflow-y-auto scrollbar scrollbar-w-2 scrollbar-h-2 scrollbar-track-base-300 scrollbar-thumb-neutral scrollbar-thumb-rounded-full">
class="basis-2/3 flex-1 overflow-y-auto scrollbar scrollbar-w-2 scrollbar-h-2 scrollbar-track-base-300 scrollbar-thumb-neutral scrollbar-thumb-rounded-full">
<app-result-table #resultTable (rowClicked)="chartSidebar.onRowClicked($event)"></app-result-table>
</div>
<div class="flex-grow-[1] min-w-[175px]">
<div class="basis-1/3 min-w-[310px] max-w-[512px]">
<app-chart-sidebar #chartSidebar></app-chart-sidebar>
</div>
</div>

View File

@@ -1,9 +1,9 @@
<div class="bg-neutral rounded-md">
<div class="text-center text-neutral-content">
{{ getEMHXString() }}
</div>
<div class="indicator">
<img class="w-12 m-2 mt-0" src="assets/images/instruments/{{ instrument }}.png" />
<span class="indicator-item indicator-bottom indicator-start badge badge-error badge-md ml-4 mb-4 font-bold">{{ getDiff() }}</span>
<div class="flex gap-2 items-center">
<img class="w-11 h-11" src="assets/images/instruments/{{ instrument }}.png" />
<div class="leading-4">
<span>Diff: {{ getDiff() }}</span>
<div>
{{ getEMHXString() }}
</div>
</div>
</div>

View File

@@ -0,0 +1,185 @@
<div
class="dropdown-content card card-compact p-2 shadow bg-neutral text-neutral-content z-10 cursor-auto border-2 border-base-300 max-w-[90vw] sm:max-w-[80vw] lg:max-w-[70vw] 2xl:max-w-[60vw]">
<div class="card-body">
<div class="flex">
<div class="flex-1">
<h1 class="menu-title pl-0 pb-0 whitespace-nowrap text-neutral-content">
DOWNLOAD FORMAT
<button class="btn btn-xs btn-circle btn-ghost" (click)="selectSngModal.showModal()">
<i class="bi bi-info-circle text-sm hover:border-b-secondary-focus"></i>
</button>
<dialog #selectSngModal id="report_modal" class="modal whitespace-normal">
<div class="modal-box bg-base-100 text-base-content flex flex-col gap-2">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">
<i class="bi bi-x-lg text-lg"></i>
</button>
</form>
<div class="flex gap-6">
<div class="flex-1">
<span class="font-bold text-lg">.sng (new)</span>
<ul class="list-disc pl-5">
<li>Single chart file</li>
<li>Can be scanned in-game directly without extracting</li>
<li>Currently only supported by YARG and Clone Hero v1.1</li>
</ul>
</div>
<div class="flex-1">
<span class="font-bold text-lg">.zip</span>
<ul class="list-disc pl-5">
<li>Contains chart folder</li>
<li>Must be extracted before it can be scanned in-game</li>
<li>Supported across many games</li>
</ul>
</div>
</div>
<br />
<div class="text-xs">
A program to convert between .sng files and chart folders can be found
<a class="link" href="https://github.com/mdsitton/SngFileFormat/releases" target="_blank">here</a>.
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</h1>
<div class="flex gap-2">
<label class="label cursor-pointer">
<input type="radio" class="radio radio-secondary mr-2" [value]="true" [formControl]="isSngControl" />
.sng
</label>
<label class="label cursor-pointer">
<input type="radio" class="radio radio-secondary mr-2" [value]="false" [formControl]="isSngControl" />
.zip
</label>
</div>
</div>
<div class="pt-2">
<button class="btn btn-secondary btn-xs flex-nowrap uppercase" (click)="reportModal.showModal()">
<i class="bi bi-exclamation-triangle text-sm text-secondary-content"></i> Report issue
</button>
<dialog #reportModal id="report_modal" class="modal">
@if (reportSent) {
<div class="modal-box bg-base-100 text-base-content flex flex-col gap-2">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">
<i class="bi bi-x-lg text-lg"></i>
</button>
</form>
<span class="text-xl text-center">{{ reportMessage }}</span>
</div>
} @else {
<div class="modal-box bg-base-100 text-base-content flex flex-col gap-2">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">
<i class="bi bi-x-lg text-lg"></i>
</button>
</form>
<h3 class="font-bold text-lg">Report Issue</h3>
<div>
@for (option of reportOptions; track $index) {
<div class="form-control">
<label class="label cursor-pointer justify-normal gap-2">
<input
type="radio"
name="selectedReportOption{{ selectedVersion.value.chartId }}"
class="radio checked:bg-red-500"
[value]="option"
[formControl]="reportOption" />
<span>{{ option }}</span>
</label>
</div>
}
</div>
<div class="form-control">
<textarea class="textarea textarea-bordered h-24" placeholder="More details (optional)" [formControl]="reportExtraInfo"></textarea>
</div>
<div class="form-control flex-row justify-end">
<button class="btn btn-primary" (click)="report()">Submit</button>
</div>
</div>
}
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div>
</div>
@if (displayVersions.length > 1) {
<h1 class="menu-title pl-0 pb-0 pt-4 whitespace-nowrap text-neutral-content">SELECT VERSION</h1>
}
<div
class="pt-2 overflow-auto scrollbar scrollbar-w-2 scrollbar-h-2 scrollbar-track-neutral scrollbar-thumb-neutral-content scrollbar-thumb-rounded-full">
<table class="table table-xs">
<thead>
<tr>
@if (displayVersions.length > 1) {
<th></th>
}
<th class="text-neutral-content">Uploaded</th>
<th class="text-neutral-content">
<span class="label-text cursor-help underline decoration-dotted" title="The MD5 hash of the chart folder or .sng file.">Hash</span>
</th>
<th class="text-neutral-content">
<span class="label-text cursor-help underline decoration-dotted" title="The MD5 hash of just the .chart or .mid file.">Chart Hash</span>
</th>
<th class="text-neutral-content">Google Drive Location</th>
</tr>
</thead>
<tbody>
@for (version of displayVersions; track version.chartId) {
<tr>
@if (displayVersions.length > 1) {
<td>
<input
type="radio"
name="selectedVersion{{ version.chartId }}"
class="radio radio-secondary"
[value]="version"
[formControl]="selectedVersion" />
</td>
}
<td>{{ version.modifiedTime | date: 'y/MM/dd' }}</td>
<td>
<div class="flex flex-nowrap items-center">
{{ version.md5.substring(0, 7) }}
<div class="tooltip tooltip-accent" data-tip="Copy full hash">
<button class="btn btn-circle btn-ghost btn-xs" (click)="copyHash(version.md5)">
<i class="bi bi-copy text-sx"></i>
</button>
</div>
</div>
</td>
<td>
<div class="flex flex-nowrap items-center">
{{ version.chartMd5.substring(0, 7) }}
<div class="tooltip tooltip-accent" data-tip="Copy full hash">
<button class="btn btn-circle btn-ghost btn-xs" (click)="copyHash(version.chartMd5)">
<i class="bi bi-copy text-sx"></i>
</button>
</div>
</div>
</td>
<td>
<div class="breadcrumbs overflow-visible">
<ul>
@for (breadcrumb of getVersionBreadcrumbs(version); track $index) {
<li>
<ng-container *ngIf="breadcrumb.link">
<a [href]="breadcrumb.link" target="_blank">{{ breadcrumb.name }}</a>
</ng-container>
<ng-container *ngIf="!breadcrumb.link">{{ breadcrumb.name }}</ng-container>
</li>
}
</ul>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -0,0 +1,92 @@
import { HttpClient } from '@angular/common/http'
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { FormControl } from '@angular/forms'
import { sortBy } from 'lodash'
import { SearchService } from 'src-angular/app/core/services/search.service'
import { environment } from 'src-angular/environments/environment'
import { ChartData } from 'src-shared/interfaces/search.interface'
import { driveLink } from 'src-shared/UtilFunctions'
@Component({
selector: 'app-chart-sidebar-menu',
templateUrl: './chart-sidebar-menu.component.html',
})
export class ChartSidebarMenutComponent implements OnInit {
@Input() chartVersions: ChartData[]
@Output() selectedVersionChanges = new EventEmitter<ChartData>()
public selectedVersion: FormControl<ChartData>
public reportOptions = [`Doesn't follow Chorus guidelines`, `Doesn't meet chart quality standards`, 'Other']
public reportOption: FormControl<string>
public reportExtraInfo: FormControl<string>
public reportSent = false
public reportMessage = ''
constructor(
private searchService: SearchService,
private http: HttpClient,
) { }
get isSngControl() {
return this.searchService.isSng
}
ngOnInit(): void {
this.selectedVersion = new FormControl<ChartData>(this.displayVersions[0], { nonNullable: true })
this.selectedVersion.valueChanges.subscribe(v => this.selectedVersionChanges.emit(v))
this.reportOption = new FormControl<string>(this.reportOptions[0], { nonNullable: true })
this.reportExtraInfo = new FormControl<string>('', { nonNullable: true })
}
get displayVersions() {
return sortBy(this.chartVersions, v => v.modifiedTime).reverse()
}
getVersionBreadcrumbs(version: ChartData) {
const breadcrumbs: { name: string; link: string | null }[] = []
breadcrumbs.push({
name: version.packName ?? `${version.applicationUsername}'s Charts`,
link: driveLink(version.applicationDriveId),
})
if (version.applicationDriveId !== version.parentFolderId) {
breadcrumbs.push({ name: version.drivePath, link: driveLink(version.parentFolderId) })
}
if (version.driveFileId) {
breadcrumbs.push({ name: version.driveFileName!, link: driveLink(version.driveFileId) })
if (version.driveChartIsPack) {
breadcrumbs.push({ name: this.joinPaths(version.archivePath!, version.chartFileName ?? ''), link: null })
}
}
return breadcrumbs
}
joinPaths(...args: string[]) {
return args.join('/')
.replace(/\/+/g, '/')
.replace(/^\/|\/$/g, '')
}
copyHash(hash: string) {
navigator.clipboard.writeText(hash)
}
report() {
this.http.post(`${environment.apiUrl}/report`, {
chartId: this.selectedVersion.value.chartId,
reason: this.reportOption.value,
extraInfo: this.reportExtraInfo.value,
}).subscribe((response: { message: string }) => {
this.reportMessage = response.message
this.reportSent = true
})
}
}

View File

@@ -1,25 +1,59 @@
<div id="sidebarCard" *ngIf="selectedChart" class="ui fluid card">
<div class="ui placeholder">
<div class="flex h-full flex-col" *ngIf="selectedChart">
<div class="relative">
@if (albumArtMd5) {
<img src="https://files.enchor.us/{{ albumArtMd5 }}.jpg" alt="Album art" loading="lazy" class="object-cover w-40" />
@if (hasIcons && icon) {
<div class="tooltip absolute bottom-3 left-3" [attr.data-tip]="iconTooltip">
<img
class="w-16"
src="https://clonehero.gitlab.io/sources/icons/{{ icon }}"
[alt]="selectedChart.icon"
(load)="iconLoading = false"
(error)="iconLoading = false"
[class.hidden]="iconLoading" />
</div>
}
@if (albumLoading) {
<div class="skeleton w-full aspect-square"></div>
}
<img
src="https://files.enchor.us/{{ albumArtMd5 }}.jpg"
alt="Album art"
(load)="albumLoading = false"
(error)="albumLoading = false"
[class.hidden]="albumLoading" />
}
</div>
<div *ngIf="charts && charts.length > 1" id="chartDropdown" class="ui fluid right labeled scrolling icon dropdown button">
<input type="hidden" name="Chart" />
<i id="chartDropdownIcon" class="dropdown icon"></i>
<div class="default text"></div>
<div id="chartDropdownMenu" class="menu"></div>
</div>
<div id="textPanel" class="content">
<span class="header">{{ selectedChart.chartName }}</span>
<div class="description">
<div *ngIf="selectedChart.chartAlbum"><b>Album:</b> {{ selectedChart.chartAlbum }}</div>
<div *ngIf="selectedChart.chartGenre"><b>Genre:</b> {{ selectedChart.chartGenre }}</div>
<div *ngIf="selectedChart.chartYear"><b>Year:</b> {{ selectedChart.chartYear }}</div>
<div><b>Charter:</b> {{ selectedChart.charter }}</div>
<div><b>Audio Length:</b> {{ songLength }}</div>
<div class="ui divider"></div>
<div class="ui horizontal list">
<div
class="flex flex-1 p-2 gap-1 justify-between overflow-x-hidden overflow-y-auto scrollbar scrollbar-w-2 scrollbar-h-2 scrollbar-track-base-300 scrollbar-thumb-neutral scrollbar-thumb-rounded-full">
<div class="flex flex-1 flex-col gap-1">
<div>
<!-- TODO: Change this to a dropdown if there is more than one chart -->
<b>Charter: </b>
<a class="link link-hover" (click)="onSourceLinkClicked()">{{ selectedChart.charter | removeStyleTags }}</a>
</div>
<div class="whitespace-nowrap">
<span class="font-bold">Length:</span>
{{ effectiveLength }} (+{{ extraLengthSeconds }}s)
<div
class="tooltip tooltip-bottom cursor-help [text-wrap:balance]"
data-tip="The time between the first and last note. The second value is the extra time at the start and end of the song without notes.">
<i class="bi bi-info-circle text-xs"></i>
</div>
</div>
<div class="flex flex-wrap">
<div class="flex-1">
@for (pair of boolProperties; track $index) {
<p class="flex items-center">
<i class="bi text-2xl -my-3" [ngClass]="pair.value ? 'bi-check2' : 'bi-x'" [ngStyle]="{ color: pair.value ? 'green' : 'red' }"> </i>
<span class="ml-1 whitespace-nowrap" [class.font-bold]="pair.value">{{ pair.text }}</span>
</p>
}
</div>
</div>
</div>
<div class="flex flex-1 flex-col gap-1">
<div
class="flex flex-wrap gap-1 max-h-40 min-h-[44px] max-w-[234px] overflow-y-auto scrollbar scrollbar-w-2 scrollbar-h-2 scrollbar-track-base-300 scrollbar-thumb-neutral scrollbar-thumb-rounded-full">
@if (selectedChart.notesData.hasVocals) {
<app-chart-sidebar-instrument [chart]="selectedChart" instrument="vocals" />
}
@@ -27,18 +61,86 @@
<app-chart-sidebar-instrument [chart]="selectedChart" [instrument]="instrument" />
}
</div>
<div id="sourceLinks">
<a id="sourceLink" (click)="onSourceLinkClicked()">{{ selectedChart.packName ?? selectedChart.applicationUsername + "'s Chart" }}</a>
<button *ngIf="shownFolderButton()" id="folderButton" class="mini ui icon button" (click)="onFolderButtonClicked()">
<i class="folder open outline icon"></i>
</button>
<div class="flex flex-1 flex-col">
@if (instruments.length > 1 || difficulties.length > 1) {
<div class="flex flex-wrap gap-1">
@if (instruments.length > 1) {
<select class="select select-bordered select-sm grow-[40]" [formControl]="instrumentDropdown">
@for (instrument of instruments; track $index) {
<option [value]="instrument">{{ shortInstrumentDisplay(instrument) }}</option>
}
</select>
}
@if (difficulties.length > 1) {
<select class="select select-bordered select-sm flex-1" [formControl]="difficultyDropdown">
@for (difficulty of difficulties; track $index) {
<option [value]="difficulty">{{ difficultyDisplay(difficulty) }}</option>
}
</select>
}
</div>
}
<p class="font-bold whitespace-nowrap">Average NPS: {{ averageNps || 'N/A' }}</p>
<p class="font-bold whitespace-nowrap">Maximum NPS: {{ maximumNps }}</p>
<p class="font-bold whitespace-nowrap">Note Count: {{ noteCount }}</p>
</div>
</div>
</div>
<div id="downloadButtons" class="ui positive buttons">
<div id="downloadButton" class="ui button" (click)="onDownloadClicked()">Download</div>
<div id="versionDropdown" class="ui floating dropdown icon button">
<i class="dropdown icon"></i>
<div class="join">
<button class="btn rounded-md flex-1 join-item btn-primary" (click)="onDownloadClicked()">Download</button>
<dialog #selectSngModal id="report_modal" class="modal">
<div class="modal-box bg-base-100 text-base-content flex flex-col gap-2">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">
<i class="bi bi-x-lg text-2xl"></i>
</button>
</form>
<h3 class="font-bold text-lg">Select Download Format:</h3>
<div class="flex gap-6">
<div class="form-control flex-1">
<label class="label cursor-pointer justify-normal gap-2">
<input type="radio" name="selectedDownloadFormat{{ selectedChart.chartId }}" class="radio" (change)="selectDownloadFormat(true)" />
<span>.sng (new)</span>
</label>
<ul class="list-disc pl-5">
<li>Single chart file</li>
<li>Can be scanned in-game directly without extracting</li>
<li>Currently only supported by YARG and Clone Hero v1.1</li>
</ul>
</div>
<div class="form-control flex-1">
<label class="label cursor-pointer justify-normal gap-2">
<input type="radio" name="selectedDownloadFormat{{ selectedChart.chartId }}" class="radio" (change)="selectDownloadFormat(false)" />
<span>.zip</span>
</label>
<ul class="list-disc pl-5">
<li>Contains chart folder</li>
<li>Must be extracted before it can be scanned in-game</li>
<li>Supported across many games</li>
</ul>
</div>
</div>
<br />
<div class="text-xs">This can be changed later in the (<i class="bi bi-three-dots align-middle"></i>) menu.</div>
<div class="text-xs">
A program to convert between .sng files and chart folders can be found
<a class="link" href="https://github.com/mdsitton/SngFileFormat/releases" target="_blank">here</a>.
</div>
<div class="form-control flex-row justify-end">
<button class="btn btn-primary" [disabled]="!hasSelectedDownloadFormat" (click)="onDownloadClicked()">Download</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<div
#menu
class="cursor-pointer bg-neutral rounded-md join-item dropdown dropdown-top dropdown-end p-1 flex items-center"
(click)="showMenu()"
[class.dropdown-open]="menuVisible">
<i class="bi bi-three-dots text-2xl px-1 text-neutral-content"></i>
<app-chart-sidebar-menu [chartVersions]="charts![0]" (selectedVersionChanges)="selectedChart = $event" />
</div>
</div>
</div>

View File

@@ -1,64 +0,0 @@
:host {
display: contents;
}
.ui.card {
display: flex;
flex-direction: column;
border-radius: 0px;
}
.ui.placeholder {
border-radius: 0px !important;
}
#textPanel {
flex-grow: 1;
overflow-y: auto;
}
#versionDropdown, #folderButton {
max-width: min-content;
}
#chartDropdown {
min-height: min-content;
margin: 0px;
}
::ng-deep #chartDropdownMenu > div.item {
white-space: normal;
word-break: break-word;
}
#sourceLinks {
display: flex;
}
#sourceLink {
align-self: center;
flex-grow: 1;
}
#chartDropdownIcon, #versionDropdown {
display: flex;
flex-direction: column;
justify-content: center;
}
.ui.horizontal.list>.item {
margin-right: 12px;
}
.ui.divider {
margin: 4px 0;
}
#downloadButton {
width: min-content;
margin: 0px 1px 1px 1px;
}
#versionDropdown {
margin: 0px 1px 1px -3px;
}

View File

@@ -1,32 +1,43 @@
import { Component, OnInit } from '@angular/core'
import { Component, ElementRef, HostBinding, OnInit, Renderer2, ViewChild } from '@angular/core'
import { FormControl } from '@angular/forms'
import { chain, flatMap, sortBy } from 'lodash'
import { Instrument } from 'scan-chart'
import { chain, compact, flatMap, intersection, round, sortBy } from 'lodash'
import { Difficulty, Instrument } from 'scan-chart'
import { SearchService } from 'src-angular/app/core/services/search.service'
import { SettingsService } from 'src-angular/app/core/services/settings.service'
import { ChartData } from 'src-shared/interfaces/search.interface'
import { driveLink, instruments } from 'src-shared/UtilFunctions'
interface Difficulty {
instrument: string
diffNumber: string
chartedDifficulties: string
}
import { setlistNames } from 'src-shared/setlist-names'
import { difficulties, difficultyDisplay, driveLink, instruments, msToRoughTime, removeStyleTags, shortInstrumentDisplay } from 'src-shared/UtilFunctions'
@Component({
selector: 'app-chart-sidebar',
templateUrl: './chart-sidebar.component.html',
styleUrls: ['./chart-sidebar.component.scss'],
})
export class ChartSidebarComponent implements OnInit {
@HostBinding('class.contents') contents = true
@ViewChild('menu') menu: ElementRef
@ViewChild('selectSngModal') selectSngModal: ElementRef<HTMLDialogElement>
public shortInstrumentDisplay = shortInstrumentDisplay
public difficultyDisplay = difficultyDisplay
private guitarlikeInstruments: Instrument[] = [
'guitar', 'guitarcoop', 'rhythm', 'bass', 'keys', 'guitarghl', 'guitarcoopghl', 'rhythmghl', 'bassghl',
]
private unlisten?: () => void
albumLoading = true
iconLoading = true
public menuVisible = false
selectedChart: ChartData | null = null
charts: ChartData[][] | null = null
songLength: string
difficultiesList: Difficulty[]
public instrumentDropdown: FormControl<Instrument | null>
public difficultyDropdown: FormControl<Difficulty | null>
constructor(
private renderer: Renderer2,
private searchService: SearchService,
public settingsService: SettingsService
) { }
@@ -36,12 +47,67 @@ export class ChartSidebarComponent implements OnInit {
this.charts = null
this.selectedChart = null
})
this.instrumentDropdown = new FormControl<Instrument | null>(this.defaultInstrument)
this.searchService.instrument.valueChanges.subscribe(instrument => {
if (this.instruments.some(i => i === instrument)) {
this.instrumentDropdown.setValue(instrument)
}
})
this.instrumentDropdown.valueChanges.subscribe(() => {
if (!this.difficulties.some(d => d === this.difficultyDropdown.value)) {
this.difficultyDropdown.setValue(this.defaultDifficulty)
}
})
this.difficultyDropdown = new FormControl<Difficulty | null>(this.defaultDifficulty)
this.searchService.difficulty.valueChanges.subscribe(difficulty => {
if (this.difficulties.some(d => d === difficulty)) {
this.difficultyDropdown.setValue(difficulty)
}
})
}
public get albumArtMd5() {
return flatMap(this.charts ?? []).find(c => !!c.albumArtMd5)?.albumArtMd5 || null
}
public get hasIcons() { return !!this.searchService.availableIcons }
public get icon() {
const iconName = this.selectedChart!.icon || removeStyleTags(this.selectedChart!.charter ?? 'N/A').toLowerCase() + '.'
if (iconName === 'unknown charter') { return null }
return this.searchService.availableIcons?.find(i => i.toLowerCase().startsWith(iconName)) || null
}
public get iconTooltip() {
if (!this.selectedChart!.icon) {
return null
}
return setlistNames[this.selectedChart!.icon] ?? null
}
public get effectiveLength() {
return msToRoughTime(this.selectedChart!.notesData.effectiveLength)
}
public get extraLengthSeconds() {
return round((this.selectedChart!.notesData.length - this.selectedChart!.notesData.effectiveLength) / 1000, 1)
}
public get boolProperties(): ({ value: boolean; text: string })[] {
const notesData = this.selectedChart!.notesData
const showGuitarlikeProperties = intersection(this.instruments, this.guitarlikeInstruments).length > 0
const showDrumlikeProperties = intersection(this.instruments, ['drums']).length > 0
return compact([
showGuitarlikeProperties ? { value: notesData.hasSoloSections, text: 'Solo Sections' } : null,
{ value: notesData.hasLyrics, text: 'Lyrics' },
showGuitarlikeProperties ? { value: notesData.hasForcedNotes, text: 'Forced Notes' } : null,
showGuitarlikeProperties ? { value: notesData.hasTapNotes, text: 'Tap Notes' } : null,
showGuitarlikeProperties ? { value: notesData.hasOpenNotes, text: 'Open Notes' } : null,
showDrumlikeProperties ? { value: notesData.has2xKick, text: '2x Kick' } : null,
showDrumlikeProperties ? { value: notesData.hasRollLanes, text: 'Roll Lanes' } : null,
{ value: this.selectedChart!.hasVideoBackground, text: 'Video Background' },
])
}
/**
* Displays the information for the selected song.
*/
@@ -51,7 +117,15 @@ export class ChartSidebarComponent implements OnInit {
.values()
.map(versionGroup => sortBy(versionGroup, vg => vg.modifiedTime).reverse())
.value()
if (this.selectedChart?.albumArtMd5 !== this.charts[0][0].albumArtMd5) {
this.albumLoading = true
}
if ((this.selectedChart?.icon || this.selectedChart?.charter) !== (this.charts[0][0].icon || this.charts[0][0].charter)) {
this.iconLoading = true
}
this.selectedChart = this.charts[0][0]
this.instrumentDropdown.setValue(this.defaultInstrument)
this.difficultyDropdown.setValue(this.defaultDifficulty)
}
/**
@@ -61,18 +135,60 @@ export class ChartSidebarComponent implements OnInit {
window.electron.emit.openUrl(driveLink(this.selectedChart!.applicationDriveId))
}
/**
* @returns `true` if the source folder button should be shown.
*/
shownFolderButton() {
return this.selectedChart!.applicationDriveId !== this.selectedChart!.parentFolderId
public get defaultInstrument() {
return this.instruments.some(i => i === this.searchService.instrument.value)
? this.searchService.instrument.value!
: this.instruments[0]
}
public get instruments(): Instrument[] {
if (!this.selectedChart) { return [] }
return chain(this.selectedChart.notesData.noteCounts)
.map(nc => nc.instrument)
.uniq()
.sortBy(i => instruments.indexOf(i))
.value()
}
public get defaultDifficulty() {
return this.difficulties.some(d => d === this.searchService.difficulty.value)
? this.searchService.difficulty.value!
: this.difficulties[0]
}
public get difficulties(): Difficulty[] {
if (!this.selectedChart) { return [] }
return chain(this.selectedChart.notesData.noteCounts)
.filter(nc => nc.instrument === this.instrumentDropdown.value && nc.count > 0)
.map(nc => nc.difficulty)
.sortBy(d => difficulties.indexOf(d))
.value()
}
/**
* Opens the chart folder in the default browser.
*/
onFolderButtonClicked() {
window.electron.emit.openUrl(driveLink(this.selectedChart!.parentFolderId))
public get averageNps() {
if (this.noteCount < 2) {
return 0
} else {
return round(this.noteCount / (this.selectedChart!.notesData.effectiveLength / 1000), 1)
}
}
private currentTrackFilter = (track: { instrument: Instrument; difficulty: Difficulty }) => {
return track.instrument === this.instrumentDropdown.value && track.difficulty === this.difficultyDropdown.value
}
public get maximumNps() {
return this.selectedChart!.notesData.maxNps.filter(this.currentTrackFilter)[0].nps
}
public get noteCount() {
return this.selectedChart!.notesData.noteCounts.filter(this.currentTrackFilter)[0].count
}
public get hasSelectedDownloadFormat() {
// TODO
return localStorage.getItem('selectedDownloadFormat') === 'true'
}
public selectDownloadFormat(isSng: boolean) {
// TODO
this.searchService.isSng.setValue(isSng)
localStorage.setItem('selectedDownloadFormat', 'true')
}
/**
@@ -80,6 +196,12 @@ export class ChartSidebarComponent implements OnInit {
*/
onDownloadClicked() {
// TODO
if (!this.hasSelectedDownloadFormat) {
this.selectSngModal.nativeElement.showModal()
return
} else {
this.selectSngModal.nativeElement.close()
}
// this.downloadService.addDownload(
// this.selectedChart.versionID, {
// chartName: this.selectedChart.chartName,
@@ -89,12 +211,16 @@ export class ChartSidebarComponent implements OnInit {
// })
}
public get instruments(): Instrument[] {
if (!this.selectedChart) { return [] }
return chain(this.selectedChart.notesData.noteCounts)
.map(nc => nc.instrument)
.uniq()
.sortBy(i => instruments.indexOf(i))
.value()
public showMenu() {
this.menuVisible = true
this.unlisten = this.renderer.listen('window', 'click', (e: Event) => {
if (this.menuVisible && !(this.menu.nativeElement as HTMLElement).contains(e.target as HTMLElement)) {
this.menuVisible = false
if (this.unlisten) {
this.unlisten()
this.unlisten = undefined
}
}
})
}
}

View File

@@ -7,3 +7,5 @@
<td>{{ song[0].artist }}</td>
<td>{{ song[0].album || 'Various' }}</td>
<td>{{ song[0].genre || 'Various' }}</td>
<td>{{ song[0].year || 'Various' }}</td>
<!-- TODO: "Various" will never display -->

View File

@@ -11,6 +11,7 @@
<th [class.sorted]="sortColumn === 'artist'" [ngClass]="sortDirection" (click)="onColClicked('artist')">Artist</th>
<th [class.sorted]="sortColumn === 'album'" [ngClass]="sortDirection" (click)="onColClicked('album')">Album</th>
<th [class.sorted]="sortColumn === 'genre'" [ngClass]="sortDirection" (click)="onColClicked('genre')">Genre</th>
<th [class.sorted]="sortColumn === 'year'" [ngClass]="sortDirection" (click)="onColClicked('year')">Year</th>
</tr>
</thead>
<tbody>

View File

@@ -23,7 +23,7 @@ export class ResultTableComponent implements OnInit {
activeSong: ChartData[] | null = null
sortDirection: 'ascending' | 'descending' = 'descending'
sortColumn: 'name' | 'artist' | 'album' | 'genre' | null = null
sortColumn: 'name' | 'artist' | 'album' | 'genre' | 'year' | null = null
constructor(
public searchService: SearchService,
@@ -49,7 +49,7 @@ export class ResultTableComponent implements OnInit {
}
}
onColClicked(column: 'name' | 'artist' | 'album' | 'genre') {
onColClicked(column: 'name' | 'artist' | 'album' | 'genre' | 'year') {
if (this.songs.length === 0) { return }
if (this.sortColumn !== column) {
this.sortColumn = column

View File

@@ -204,19 +204,6 @@
<div class="flex flex-col justify-between">
<div class="flex gap-2">
<div class="flex flex-col">
<div class="form-control">
<label class="label cursor-pointer justify-normal gap-2">
<input
#hasSoloSections
type="checkbox"
class="toggle toggle-sm"
[indeterminate]="true"
(click)="clickCheckbox('hasSoloSections', $event)" />
<span class="label-text" [class.text-opacity-70]="formValue('hasSoloSections') === null">
{{ formValue('hasSoloSections') === false ? 'No ' : '' }}Solo Sections
</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-normal gap-2">
<input
@@ -256,6 +243,19 @@
</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-normal gap-2">
<input
#hasSoloSections
type="checkbox"
class="toggle toggle-sm"
[indeterminate]="true"
(click)="clickCheckbox('hasSoloSections', $event)" />
<span class="label-text" [class.text-opacity-70]="formValue('hasSoloSections') === null">
{{ formValue('hasSoloSections') === false ? 'No ' : '' }}Solo Sections
</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-normal gap-2">
<input type="checkbox" class="toggle toggle-sm" [indeterminate]="true" (click)="clickCheckbox('hasLyrics', $event)" />

View File

@@ -139,14 +139,12 @@ export class SearchBarComponent implements OnInit, AfterViewInit {
const isAny = this.searchService.instrument.value === null
const explanation = 'Not available for the current instrument.'
this.hasSoloSections.nativeElement.disabled = isDrums && !isAny
this.hasForcedNotes.nativeElement.disabled = isDrums && !isAny
this.hasOpenNotes.nativeElement.disabled = isDrums && !isAny
this.hasTapNotes.nativeElement.disabled = isDrums && !isAny
this.hasRollLanes.nativeElement.disabled = !isDrums && !isAny
this.has2xKick.nativeElement.disabled = !isDrums && !isAny
this.hasSoloSections.nativeElement.title = isDrums && !isAny ? explanation : ''
this.hasForcedNotes.nativeElement.title = isDrums && !isAny ? explanation : ''
this.hasOpenNotes.nativeElement.title = isDrums && !isAny ? explanation : ''
this.hasTapNotes.nativeElement.title = isDrums && !isAny ? explanation : ''
@@ -155,11 +153,9 @@ export class SearchBarComponent implements OnInit, AfterViewInit {
if (!isAny) {
if (isDrums) {
this.advancedSearchForm.get('hasSoloSections')?.setValue(null)
this.advancedSearchForm.get('hasForcedNotes')?.setValue(null)
this.advancedSearchForm.get('hasOpenNotes')?.setValue(null)
this.advancedSearchForm.get('hasTapNotes')?.setValue(null)
this.hasSoloSections.nativeElement.indeterminate = true
this.hasForcedNotes.nativeElement.indeterminate = true
this.hasOpenNotes.nativeElement.indeterminate = true
this.hasTapNotes.nativeElement.indeterminate = true

View File

@@ -0,0 +1,12 @@
import { Pipe, PipeTransform } from '@angular/core'
import { removeStyleTags } from 'src-shared/UtilFunctions'
@Pipe({
name: 'removeStyleTags',
})
export class RemoveStyleTagsPipe implements PipeTransform {
transform(value: string | null): string {
return value ? removeStyleTags(value) : 'N/A'
}
}

View File

@@ -1,3 +1,4 @@
import _ from 'lodash'
import sanitize from 'sanitize-filename'
import { Difficulty, Instrument } from 'scan-chart'
@@ -95,6 +96,21 @@ export function instrumentDisplay(instrument: Instrument | null) {
case null: return 'Any Instrument'
}
}
export function shortInstrumentDisplay(instrument: Instrument | null) {
switch (instrument) {
case 'guitar': return 'Guitar'
case 'guitarcoop': return 'Co-op'
case 'rhythm': return 'Rhythm'
case 'bass': return 'Bass'
case 'drums': return 'Drums'
case 'keys': return 'Keys'
case 'guitarghl': return 'GHL Guitar'
case 'guitarcoopghl': return 'GHL Co-op'
case 'rhythmghl': return 'GHL Rhythm'
case 'bassghl': return 'GHL Bass'
case null: return 'Any Instrument'
}
}
export function difficultyDisplay(difficulty: Difficulty | null) {
switch (difficulty) {
case 'expert': return 'Expert'
@@ -119,3 +135,34 @@ export function instrumentToDiff(instrument: Instrument | 'vocals') {
case 'vocals': return 'diff_vocals'
}
}
/**
* @returns a string representation of `ms` that looks like HH:MM:SS
*/
export function msToRoughTime(ms: number) {
const seconds = _.round((ms / 1000) % 60)
const minutes = Math.floor((ms / 1000 / 60) % 60)
const hours = Math.floor((ms / 1000 / 60 / 60) % 24)
return `${hours ? `${hours}:` : ''}${minutes}:${_.padStart(String(seconds), 2, '0')}`
}
const allowedTags = [
'align', 'allcaps', 'alpha', 'b', 'br', 'color', 'cspace', 'font', 'font-weight',
'gradient', 'i', 'indent', 'line-height', 'line-indent', 'link', 'lowercase',
'margin', 'mark', 'mspace', 'nobr', 'noparse', 'page', 'pos', 'rotate', 's',
'size', 'smallcaps', 'space', 'sprite', 'strikethrough', 'style', 'sub', 'sup',
'u', 'uppercase', 'voffset', 'width',
]
const tagPattern = allowedTags.map(tag => `\\b${tag}\\b`).join('|')
/**
* @returns `text` with all style tags removed. (e.g. "<color=#AEFFFF>Aren Eternal</color> & Geo" -> "Aren Eternal & Geo")
*/
export function removeStyleTags(text: string) {
let oldText = text
let newText = text
do {
oldText = newText
newText = newText.replace(new RegExp(`<\\s*\\/?\\s*(?:${tagPattern})[^>]*>`, 'gi'), '').trim()
} while (newText !== oldText)
return newText
}

193
src-shared/setlist-names.ts Normal file
View File

@@ -0,0 +1,193 @@
/* eslint-disable @typescript-eslint/naming-convention */
export const setlistNames = {
'311hero': '311 Hero',
'a7x': 'A7X Hero',
'ae': 'Avatar Emporium',
'aep': 'Eternalized',
'ah1': 'Angevil Hero',
'ah2': 'Angevil Hero 2',
'ah3': 'Angevil Hero 3',
'ah4': 'Angevil Hero 4',
'ah5': 'Angevil Hero 5',
'ahbe': 'Antihero: Beach Episode',
'anime': 'RdNetwork\'s Anime/Weeb charts',
'antihero': 'Antihero',
'antihero2': 'Antihero 2',
'b-m': 'Band-Maid',
'bh': 'Band Hero',
'bhdlc': 'Band Hero - DLC',
'bhds': 'Band Hero DS',
'blackhole': 'Black Hole',
'bodom': 'Bodom Hero',
'bs': 'Blanket Statement',
'casp': 'Community Artists and Streamers Project',
'cavequest': 'Cave Quest',
'cb': 'Circuit Breaker',
'ccc': 'Customs Creators Collective',
'ch-on': 'CH-ON',
'chan': 'CHAN',
'charts': 'C.H.A.R.T.S.',
'charts2': 'C.H.A.R.T.S. 2',
'chilehero': 'Chile Hero',
'cob': 'Children Of Bodom',
'codered': 'Code Red',
'cowhero': 'Cow Hero',
'cowherodlc1': 'Cow Hero - DLC 1',
'cowherodlc2': 'Cow Hero - DLC 2',
'cowherodlc3': 'Cow Hero - DLC 3',
'creativech': 'Creative Commons Hero',
'csc': 'Custom Songs Central',
'cst': 'Cover Setlist Team',
'cth1': 'Carpal Tunnel Hero',
'cth1r': 'Carpal Tunnel Hero: Remastered',
'cth2': 'Carpal Tunnel Hero 2',
'cth3': 'Carpal Tunnel Hero 3',
'ctp6': 'Community Track Pack 6',
'ddlc': 'Doki Doki Literature Club',
'dfch': 'DragonForce Discography Rechart',
'dhc': 'Djent Hero Collection',
'digi': 'Digitizer',
'dissonancehero': 'Dissonance Hero',
'djenthero': 'Djent Hero',
'djentherodlc': 'Djent Hero - DLC',
'djh': 'DJ Hero',
'djmax': 'DJMax Pack',
'doomhero': 'Doom Hero',
'dp9': 'Drum Project IX',
'eds': 'Ensiferum Discography Setlist',
'efhiii': 'EFHIII',
'enh': 'Endless Hero',
'extremehero': 'Extreme Hero',
'fab4disc': 'FAB4DISC',
'facelift': 'Facelift',
'fo2': 'FlatOut 2 Soundtrack',
'fof': 'Frets on Fire',
'fp': 'Focal Point',
'fp2': 'Focal Point 2',
'france': 'RdNetwork\'s French charts',
'french': 'HexaNation',
'fuse': 'Fuse Box',
'gdrb': 'Green Day: Rock Band',
'gdrbdlc': 'Green Day: Rock Band - DLC',
'gdrbold': 'Green Day: Rock Band (old)',
'gf1': 'GuitarFreaks',
'gf2dm1': 'GuitarFreaks 2ndMIX & DrumMania',
'gh1': 'Guitar Hero I',
'gh2': 'Guitar Hero II',
'gh2dlc': 'Guitar Hero II - DLC',
'gh3': 'Guitar Hero III',
'gh3dlc': 'Guitar Hero III - DLC',
'gh5': 'Guitar Hero 5',
'gh5dlc': 'Guitar Hero 5 - DLC',
'gh80s': 'Guitar Hero Encore: Rocks the 80s',
'gha': 'Guitar Hero: Aerosmith',
'ghl': 'Guitar Hero Live',
'ghm': 'Guitar Hero: Metallica',
'ghmdlc': 'Guitar Hero: Metallica - DLC',
'ghot': 'Guitar Hero: On Tour',
'ghotd': 'Guitar Hero On Tour: Decades',
'ghotmh': 'Guitar Hero On Tour: Modern Hits',
'ghsh': 'Guitar Hero: Smash Hits',
'ghtv': 'Guitar Hero TV',
'ghvh': 'Guitar Hero: Van Halen',
'ghwor': 'Guitar Hero: Warriors of Rock',
'ghwordlc': 'Guitar Hero: Warriors of Rock - DLC',
'ghwt': 'Guitar Hero: World Tour',
'ghwtdlc': 'Guitar Hero: World Tour - DLC',
'ghxsetlist': 'Guitar Hero X',
'gp': 'Guitar Praise',
'guitarzero2': 'Guitar Zero 2',
'guitarzerodlc': 'Guitar Zero - DLC',
'heavyloadhero': 'Heavy Load Hero',
'jbs': 'JoJo\'s Bizarre Adventure Part 1: Phantom Blood Chart Pack',
'kh': 'Koreaboo Hero',
'kh2': 'Koreaboo Hero 2',
'kldd': 'KILL LINCOLN - DESTRUCTIVE DELUXE',
'klok': 'Klok Hero',
'lb': 'Lovebites',
'lisarb': 'LISARB',
'lisarb2': 'LISARB 2',
'lisarb3': 'LISARB 3',
'log': 'Lamb of God',
'lrb': 'LEGO Rock Band',
'lrbdlc': 'LEGO Rock Band - DLC',
'ma': 'Max Altitude',
'mania': 'Mania Hero',
'marathon': 'Marathon Hero',
'marathonhero2': 'Marathon Hero 2',
'mh2': 'Marathon Hero 2',
'mh_aren': 'Mania Hero',
'mh_asriel': 'Mania Hero',
'mh_dex': 'Mania Hero',
'mh_fluffy': 'Mania Hero',
'mh_ori': 'Mania Hero',
'mh_sasquatch': 'Mania Hero',
'mh_sickliff': 'Mania Hero',
'mh_skittlecouch': 'Mania Hero',
'mh_supra': 'Mania Hero',
'monstercat': 'Monstercat',
'ms3': 'Monstrão of Rock 3',
'ms4': 'Monstrão of Rock 4',
'newbeat': 'New Beat',
'nirvanasp': 'Nirvana Full Discography',
'osthero': 'OST Hero',
'peckhero': 'Peck Hero',
'pedahero': 'Peda Hero',
'pg': 'Power Gig: Rise of the SixString',
'pgh2': 'Project Remaster: Guitar Hero II',
'ph': 'Parallax Hero',
'ph1': 'Puppetz Hero I',
'ph2': 'Puppetz Hero II',
'ph3': 'Puppetz Hero III',
'ph4': 'Puppetz Hero IV',
'phaseshift': 'Phase Shift Setlist',
'phishballad': 'PhxPhishPhan\'s Ballad Pack REDUX',
'psgp2': 'Phase Shift Guitar Project 2',
'psgp3': 'Phase Shift Guitar Project 3',
'psgp4': 'Phase Shift Guitar Project 4',
'pstr': 'Project Strandberger',
'ra': 'Redemption Arc',
'rb1': 'Rock Band 1',
'rb1dlc': 'Rock Band 1 - DLC',
'rb2': 'Rock Band 2',
'rb2dlc': 'Rock Band 2 - DLC',
'rb3': 'Rock Band 3',
'rb3dlc': 'Rock Band 3 - DLC',
'rb4': 'Rock Band 4',
'rb4dlc': 'Rock Band 4 - DLC',
'rbacdc': 'AC/DC Live: Rock Band Track Pack',
'rbb': 'Rock Band: Blitz',
'rbdlc': 'Rock Band - DLC',
'rbn': 'Rock Band Network',
'revolved': 'Revolved',
'rocklist': 'Rocklist',
'rr': 'Rock Revolution',
'rsp': 'Russian Songs Pack',
'sdvx1': 'Sound Voltex 1',
'sdvx3': 'Sound Voltex 3',
'sdvx4': 'Sound Voltex 4',
'sdvx5': 'Sound Voltex 5',
'se': 'Symphonic Effect',
'soz1': 'State of Zen 1',
'soz2': 'State of Zen 2',
'spcted': 'Space Teddy',
'stems': 'S.T.E.M. PROJECT',
'swcb': 'SwooshyCueb',
'sxdisc': 'Symphony X Discography Setlist',
'tbrb': 'The Beatles: Rock Band',
'tbrbdlc': 'The Beatles: Rock Band - DLC',
'tds': 'Technical Difficulties Series Packs',
'tfoth': 'The Fall of Troy Hero',
'tmdiscog': 'Tokyo Machine Discography',
'um': 'Unlucky Morpheus',
'unsung': 'Unsung Hero',
'vortex_hero': 'Vortex Hero',
'vu': 'Verified Unverified',
'wcc': 'World Charts Community',
'wccg': 'World Charts Community',
'wccs': 'World Charts Community',
'wh': 'Weed Hero',
'wh2': 'Weed Hero 2',
'zerogravity': 'Zero Gravity',
'zgsb': 'Zero Gravity: Space Battle',
} as { [icon: string]: string }