Interface conversion, search bar layout

This commit is contained in:
Geomitron
2023-12-09 18:21:01 -06:00
parent d689843f27
commit ece0f75b99
37 changed files with 1531 additions and 760 deletions

View File

@@ -1,10 +1,12 @@
import { HttpClientModule } from '@angular/common/http'
import { NgModule } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { BrowserModule } from '@angular/platform-browser'
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 { 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'
@@ -25,6 +27,7 @@ import { ProgressBarDirective } from './core/directives/progress-bar.directive'
StatusBarComponent,
ResultTableComponent,
ChartSidebarComponent,
ChartSidebarInstrumentComponent,
ResultTableRowComponent,
DownloadsModalComponent,
ProgressBarDirective,
@@ -35,6 +38,8 @@ import { ProgressBarDirective } from './core/directives/progress-bar.directive'
BrowserModule,
AppRoutingModule,
FormsModule,
ReactiveFormsModule,
HttpClientModule,
],
bootstrap: [AppComponent],
})

View File

@@ -0,0 +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>
</div>

View File

@@ -0,0 +1,41 @@
import { Component, Input } from '@angular/core'
import { capitalize } from 'lodash'
import { Instrument } from 'scan-chart'
import { ChartData } from 'src-shared/interfaces/search.interface'
import { instrumentToDiff } from 'src-shared/UtilFunctions'
@Component({
selector: 'app-chart-sidebar-instrument',
templateUrl: './chart-sidebar-instrument.component.html',
})
export class ChartSidebarInstrumentComponent {
@Input() chart: ChartData
@Input() instrument: Instrument | 'vocals'
getDiff() {
const diff = this.chart[instrumentToDiff(this.instrument)]
return diff === null || diff < 0 ? '?' : diff
}
getEMHXString() {
if (this.instrument === 'vocals') { return 'Vocals' }
const difficulties = this.chart.notesData.noteCounts
.filter(nc => nc.instrument === this.instrument && nc.count > 0)
.map(nc => nc.difficulty)
if (difficulties.length === 1) {
return capitalize(difficulties[0])
}
let str = ''
if (difficulties.includes('easy')) { str += 'E' }
if (difficulties.includes('medium')) { str += 'M' }
if (difficulties.includes('hard')) { str += 'H' }
if (difficulties.includes('expert')) { str += 'X' }
return str
}
}

View File

@@ -1,36 +1,34 @@
<div id="sidebarCard" *ngIf="selectedVersion" class="ui fluid card">
<div class="ui placeholder" [ngClass]="{ placeholder: albumArtSrc === '', inverted: settingsService.theme === 'dark' }">
<img *ngIf="albumArtSrc !== null" class="ui square image" [src]="albumArtSrc" />
<div id="sidebarCard" *ngIf="selectedChart" class="ui fluid card">
<div class="ui placeholder">
@if (albumArtMd5) {
<img src="https://files.enchor.us/{{ albumArtMd5 }}.jpg" alt="Album art" loading="lazy" class="object-cover w-40" />
}
</div>
<div *ngIf="charts.length > 1" id="chartDropdown" class="ui fluid right labeled scrolling icon dropdown button">
<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">{{ selectedVersion.chartName }}</span>
<span class="header">{{ selectedChart.chartName }}</span>
<div class="description">
<div *ngIf="songResult!.album === null"><b>Album:</b> {{ selectedVersion.album }} ({{ selectedVersion.year }})</div>
<div *ngIf="songResult!.album !== null"><b>Year:</b> {{ selectedVersion.year }}</div>
<div *ngIf="songResult!.genre === null"><b>Genre:</b> {{ selectedVersion.genre }}</div>
<div>
<b>{{ charterPlural }}</b> {{ selectedVersion.charters }}
</div>
<div *ngIf="selectedVersion.tags"><b>Tags:</b> {{ selectedVersion.tags }}</div>
<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 *ngFor="let difficulty of difficultiesList" class="item">
<img class="ui avatar image" src="assets/images/instruments/{{ difficulty.instrument }}" />
<div class="content">
<div class="header">Diff: {{ difficulty.diffNumber }}</div>
{{ difficulty.chartedDifficulties }}
</div>
</div>
@if (selectedChart.notesData.hasVocals) {
<app-chart-sidebar-instrument [chart]="selectedChart" instrument="vocals" />
}
@for (instrument of instruments; track $index) {
<app-chart-sidebar-instrument [chart]="selectedChart" [instrument]="instrument" />
}
</div>
<div id="sourceLinks">
<a id="sourceLink" (click)="onSourceLinkClicked()">{{ selectedVersion.driveData.source.sourceName }}</a>
<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>
@@ -38,8 +36,8 @@
</div>
</div>
<div id="downloadButtons" class="ui positive buttons">
<div id="downloadButton" class="ui button" (click)="onDownloadClicked()">{{ downloadButtonText }}</div>
<div *ngIf="getSelectedChartVersions().length > 1" id="versionDropdown" class="ui floating dropdown icon button">
<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>
</div>

View File

@@ -1,13 +1,11 @@
import { Component, OnInit } from '@angular/core'
import { SafeUrl } from '@angular/platform-browser'
import { chain, flatMap, sortBy } from 'lodash'
import { 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 { SongResult } from '../../../../../src-shared/interfaces/search.interface'
import { ChartedDifficulty, getInstrumentIcon, Instrument, VersionResult } from '../../../../../src-shared/interfaces/songDetails.interface'
import { groupBy } from '../../../../../src-shared/UtilFunctions'
import { DownloadService } from '../../../core/services/download.service'
import { ChartData } from 'src-shared/interfaces/search.interface'
import { driveLink, instruments } from 'src-shared/UtilFunctions'
interface Difficulty {
instrument: string
@@ -22,252 +20,81 @@ interface Difficulty {
})
export class ChartSidebarComponent implements OnInit {
songResult: SongResult | undefined
selectedVersion: VersionResult
charts: VersionResult[][]
selectedChart: ChartData | null = null
charts: ChartData[][] | null = null
albumArtSrc: SafeUrl = ''
charterPlural: string
songLength: string
difficultiesList: Difficulty[]
downloadButtonText: string
constructor(
private downloadService: DownloadService,
private searchService: SearchService,
public settingsService: SettingsService
) { }
ngOnInit() {
this.searchService.onNewSearch(() => {
this.selectVersion(undefined)
this.songResult = undefined
this.searchService.searchUpdated.subscribe(() => {
this.charts = null
this.selectedChart = null
})
}
public get albumArtMd5() {
return flatMap(this.charts ?? []).find(c => !!c.albumArtMd5)?.albumArtMd5 || null
}
/**
* Displays the information for the selected song.
*/
async onRowClicked(result: SongResult) {
if (this.songResult === undefined || result.id !== this.songResult.id) { // Clicking the same row again will not reload
this.songResult = result
const results = await window.electron.invoke.getSongDetails(result.id)
this.charts = groupBy(results, 'chartID').sort((v1, v2) => v1[0].chartName.length - v2[0].chartName.length)
this.sortCharts()
await this.selectChart(this.charts[0][0].chartID)
this.initChartDropdown()
}
}
/**
* Sorts `this.charts` and its subarrays in the correct order.
* The chart dropdown should display in a random order, but verified charters are prioritized.
* The version dropdown should be ordered by lastModified date.
* (but prefer the non-pack version if it's only a few days older)
*/
private sortCharts() {
for (const chart of this.charts) {
// TODO: sort by verified charter
this.searchService.sortChart(chart)
}
}
/**
* Initializes the chart dropdown from `this.charts` (or removes it if there's only one chart).
*/
private initChartDropdown() {
// TODO
// const values = this.charts.map(chart => {
// const version = chart[0]
// return {
// value: version.chartID,
// text: version.chartName,
// name: `${version.chartName} <b>[${version.charters}]</b>`,
// }
// })
// const $chartDropdown = $('#chartDropdown')
// $chartDropdown.dropdown('setup menu', { values })
// $chartDropdown.dropdown('setting', 'onChange', (chartID: number) => this.selectChart(chartID))
// $chartDropdown.dropdown('set selected', values[0].value)
}
private async selectChart(chartID: number) {
const chart = this.charts.find(chart => chart[0].chartID === chartID)!
await this.selectVersion(chart[0])
this.initVersionDropdown()
}
/**
* Updates the sidebar to display the metadata for `selectedVersion`.
*/
async selectVersion(selectedVersion: VersionResult | undefined) {
this.selectedVersion = selectedVersion!
await new Promise<void>(resolve => setTimeout(() => resolve(), 0)) // Wait for *ngIf to update DOM
if (this.selectedVersion !== undefined) {
this.updateCharterPlural()
this.updateSongLength()
this.updateDifficultiesList()
this.updateDownloadButtonText()
}
}
/**
* Chooses to display 'Charter:' or 'Charters:'.
*/
private updateCharterPlural() {
this.charterPlural = this.selectedVersion.charterIDs.split('&').length === 1 ? 'Charter:' : 'Charters:'
}
/**
* Converts `this.selectedVersion.chartMetadata.length` into a readable duration.
*/
private updateSongLength() {
let seconds = this.selectedVersion.songLength
if (seconds < 60) { this.songLength = `${seconds} second${seconds === 1 ? '' : 's'}`; return }
let minutes = Math.floor(seconds / 60)
let hours = 0
while (minutes > 59) {
hours++
minutes -= 60
}
seconds = Math.floor(seconds % 60)
this.songLength = `${hours === 0 ? '' : hours + ':'}${minutes === 0 ? '' : minutes + ':'}${seconds < 10 ? '0' + seconds : seconds}`
}
/**
* Updates `dfficultiesList` with the difficulty information for the selected version.
*/
private updateDifficultiesList() {
const instruments = Object.keys(this.selectedVersion.chartData.noteCounts) as Instrument[]
this.difficultiesList = []
for (const instrument of instruments) {
if (instrument !== 'undefined') {
this.difficultiesList.push({
instrument: getInstrumentIcon(instrument),
diffNumber: this.getDiffNumber(instrument),
chartedDifficulties: this.getChartedDifficultiesText(instrument),
})
}
}
}
/**
* @returns a string describing the difficulty number in the selected version.
*/
private getDiffNumber(instrument: Instrument) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const diffNumber: number = (this.selectedVersion as any)[`diff_${instrument}`]
return diffNumber === -1 || diffNumber === undefined ? 'Unknown' : String(diffNumber)
}
/**
* @returns a string describing the list of charted difficulties in the selected version.
*/
private getChartedDifficultiesText(instrument: Instrument) {
const difficulties = Object.keys(this.selectedVersion.chartData.noteCounts[instrument]) as ChartedDifficulty[]
if (difficulties.length === 4) { return 'Full Difficulty' }
const difficultyNames = []
if (difficulties.includes('x')) { difficultyNames.push('Expert') }
if (difficulties.includes('h')) { difficultyNames.push('Hard') }
if (difficulties.includes('m')) { difficultyNames.push('Medium') }
if (difficulties.includes('e')) { difficultyNames.push('Easy') }
return difficultyNames.join(', ')
}
/**
* Chooses the text to display on the download button.
*/
private updateDownloadButtonText() {
this.downloadButtonText = 'Download'
if (this.selectedVersion.driveData.inChartPack) {
this.downloadButtonText += ' Chart Pack'
} else {
this.downloadButtonText += (this.selectedVersion.driveData.isArchive ? ' Archive' : ' Files')
}
if (this.getSelectedChartVersions().length > 1) {
if (this.selectedVersion.versionID === this.selectedVersion.latestVersionID) {
this.downloadButtonText += ' (Latest)'
} else {
this.downloadButtonText += ` (${this.getLastModifiedText(this.selectedVersion.lastModified)})`
}
}
}
/**
* Initializes the version dropdown from `this.selectedVersion` (or removes it if there's only one version).
*/
private initVersionDropdown() {
// TODO
// const $versionDropdown = $('#versionDropdown')
// const versions = this.getSelectedChartVersions()
// const values = versions.map(version => ({
// value: version.versionID,
// text: 'Uploaded ' + this.getLastModifiedText(version.lastModified),
// name: 'Uploaded ' + this.getLastModifiedText(version.lastModified),
// }))
// $versionDropdown.dropdown('setup menu', { values })
// $versionDropdown.dropdown('setting', 'onChange', (versionID: number) => {
// this.selectVersion(versions.find(version => version.versionID === versionID))
// })
// $versionDropdown.dropdown('set selected', values[0].value)
}
/**
* Returns the list of versions for the selected chart, sorted by `lastModified`.
*/
getSelectedChartVersions() {
return this.charts.find(chart => chart[0].chartID === this.selectedVersion.chartID)!
}
/**
* Converts the <lastModified> value to a user-readable format.
* @param lastModified The UNIX timestamp for the lastModified date.
*/
private getLastModifiedText(lastModified: string) {
const date = new Date(lastModified)
const day = date.getDate().toString().padStart(2, '0')
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const year = date.getFullYear().toString().substr(-2)
return `${month}/${day}/${year}`
async onRowClicked(song: ChartData[]) {
this.charts = chain(song)
.groupBy(c => c.versionGroupId)
.values()
.map(versionGroup => sortBy(versionGroup, vg => vg.modifiedTime).reverse())
.value()
this.selectedChart = this.charts[0][0]
}
/**
* Opens the proxy link or source folder in the default browser.
*/
onSourceLinkClicked() {
const source = this.selectedVersion.driveData.source
window.electron.emit.openUrl(source.proxyLink ?? `https://drive.google.com/drive/folders/${source.sourceDriveID}`)
window.electron.emit.openUrl(driveLink(this.selectedChart!.applicationDriveId))
}
/**
* @returns `true` if the source folder button should be shown.
*/
shownFolderButton() {
const driveData = this.selectedVersion.driveData
return driveData.source.proxyLink || driveData.source.sourceDriveID !== driveData.folderID
return this.selectedChart!.applicationDriveId !== this.selectedChart!.parentFolderId
}
/**
* Opens the chart folder in the default browser.
*/
onFolderButtonClicked() {
window.electron.emit.openUrl(`https://drive.google.com/drive/folders/${this.selectedVersion.driveData.folderID}`)
window.electron.emit.openUrl(driveLink(this.selectedChart!.parentFolderId))
}
/**
* Adds the selected version to the download queue.
*/
onDownloadClicked() {
this.downloadService.addDownload(
this.selectedVersion.versionID, {
chartName: this.selectedVersion.chartName,
artist: this.songResult!.artist,
charter: this.selectedVersion.charters,
driveData: this.selectedVersion.driveData,
})
// TODO
// this.downloadService.addDownload(
// this.selectedChart.versionID, {
// chartName: this.selectedChart.chartName,
// artist: this.songResult!.artist,
// charter: this.selectedChart.charters,
// driveData: this.selectedChart.driveData,
// })
}
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()
}
}

View File

@@ -4,9 +4,8 @@
</div>
</td>
<td>
<span id="chartCount" *ngIf="result.chartCount > 1">{{ result.chartCount }}</span
>{{ result.name }}
<span id="chartCount" *ngIf="song.length > 1">{{ song.length }}</span> {{ song[0].name }}
</td>
<td>{{ result.artist }}</td>
<td>{{ result.album || 'Various' }}</td>
<td>{{ result.genre || 'Various' }}</td>
<td>{{ song[0].artist }}</td>
<td>{{ song[0].album || 'Various' }}</td>
<td>{{ song[0].genre || 'Various' }}</td>

View File

@@ -1,6 +1,6 @@
import { AfterViewInit, Component, ElementRef, Input, ViewChild } from '@angular/core'
import { SongResult } from '../../../../../../src-shared/interfaces/search.interface'
import { ChartData } from '../../../../../../src-shared/interfaces/search.interface'
import { SelectionService } from '../../../../core/services/selection.service'
@Component({
@@ -9,14 +9,14 @@ import { SelectionService } from '../../../../core/services/selection.service'
styleUrls: ['./result-table-row.component.scss'],
})
export class ResultTableRowComponent implements AfterViewInit {
@Input() result: SongResult
@Input() song: ChartData[]
@ViewChild('checkbox', { static: true }) checkbox: ElementRef
constructor(private selectionService: SelectionService) { }
get songID() {
return this.result.id
return this.song[0].songId ?? this.song[0].chartId
}
ngAfterViewInit() {

View File

@@ -19,12 +19,8 @@
</tr>
</thead>
<tbody>
<tr
app-result-table-row
#tableRow
*ngFor="let result of results"
(click)="onRowClicked(result)"
[class.active]="activeRowID === result.id"
[result]="result"></tr>
@for (song of songs; track song) {
<tr app-result-table-row (click)="onRowClicked(song)" [class.active]="activeSong === song" [song]="song"></tr>
}
</tbody>
</table>

View File

@@ -2,8 +2,8 @@ import { Component, EventEmitter, OnInit, Output, QueryList, ViewChild, ViewChil
import Comparators from 'comparators'
import { SettingsService } from 'src-angular/app/core/services/settings.service'
import { ChartData } from 'src-shared/interfaces/search.interface'
import { SongResult } from '../../../../../src-shared/interfaces/search.interface'
import { CheckboxDirective } from '../../../core/directives/checkbox.directive'
import { SearchService } from '../../../core/services/search.service'
import { SelectionService } from '../../../core/services/selection.service'
@@ -16,18 +16,17 @@ import { ResultTableRowComponent } from './result-table-row/result-table-row.com
})
export class ResultTableComponent implements OnInit {
@Output() rowClicked = new EventEmitter<SongResult>()
@Output() rowClicked = new EventEmitter<ChartData[]>()
@ViewChild(CheckboxDirective, { static: true }) checkboxColumn: CheckboxDirective
@ViewChildren('tableRow') tableRows: QueryList<ResultTableRowComponent>
results: SongResult[] = []
activeRowID: number | null = null
activeSong: ChartData[] | null = null
sortDirection: 'ascending' | 'descending' = 'descending'
sortColumn: 'name' | 'artist' | 'album' | 'genre' | null = null
constructor(
private searchService: SearchService,
public searchService: SearchService,
private selectionService: SelectionService,
public settingsService: SettingsService
) { }
@@ -37,24 +36,25 @@ export class ResultTableComponent implements OnInit {
this.checkboxColumn.check(selected)
})
this.searchService.onSearchChanged(results => {
this.activeRowID = null
this.results = results
this.searchService.searchUpdated.subscribe(() => {
this.activeSong = null
this.updateSort()
})
this.searchService.onNewSearch(() => {
this.sortColumn = null
})
}
onRowClicked(result: SongResult) {
this.activeRowID = result.id
this.rowClicked.emit(result)
get songs() {
return this.searchService.groupedSongs
}
onRowClicked(song: ChartData[]) {
if (this.activeSong !== song) {
this.activeSong = song
this.rowClicked.emit(song)
}
}
onColClicked(column: 'name' | 'artist' | 'album' | 'genre') {
if (this.results.length === 0) { return }
if (this.songs.length === 0) { return }
if (this.sortColumn !== column) {
this.sortColumn = column
this.sortDirection = 'descending'
@@ -68,7 +68,7 @@ export class ResultTableComponent implements OnInit {
private updateSort() {
if (this.sortColumn !== null) {
this.results.sort(Comparators.comparing(this.sortColumn, { reversed: this.sortDirection === 'ascending' }))
this.songs.sort(Comparators.comparing(this.sortColumn, { reversed: this.sortDirection === 'ascending' }))
}
}

View File

@@ -1,4 +1,4 @@
<div id="searchMenu" class="ui bottom attached borderless menu">
<!-- <div id="searchMenu" class="ui bottom attached borderless menu">
<div class="item">
<div class="ui icon input" [class.loading]="isLoading()">
<input #searchBox type="text" placeholder=" Search..." (keyup.enter)="onSearch(searchBox.value)" />
@@ -237,4 +237,334 @@
</div>
</div>
</div>
</div> -->
<div class="collapse navbar grid bg-base-100 overflow-visible rounded-none" [ngClass]="showAdvanced ? 'collapse-open' : 'collapse-close'">
<div class="flex flex-wrap justify-end gap-1">
<!-- Search Input -->
<div class="flex items-center order-3 md:order-2 xl:order-2 flex-none w-full md:w-auto md:flex-1 xl:flex-grow-[6] min-w-[21rem]">
<div class="form-control w-full">
<input type="text" [formControl]="searchControl" placeholder="What do you feel like playing today?" class="input input-bordered pr-14" />
</div>
<i class="bi bi-search -ml-11"></i>
</div>
<div class="basis-full h-0 order-4 xl:order-7"></div>
<div class="flex order-5 md:order-5 xl:order-3">
<!-- Instrument Dropdown -->
<div class="dropdown">
<label tabindex="0" class="btn btn-neutral rounded-btn rounded-r-none my-1">
@if (instrument) {
<img class="w-8 hidden sm:block" src="assets/images/instruments/{{ instrument }}.png" />
}
{{ instrumentDisplay(instrument) }}
</label>
<ul tabindex="0" class="menu dropdown-content z-[2] p-2 shadow bg-neutral text-neutral-content rounded-box w-64">
<li>
<a (click)="setInstrument(null, $event)">{{ instrumentDisplay(null) }}</a>
</li>
@for (instrument of instruments; track $index) {
<li>
<a (click)="setInstrument(instrument, $event)">
<img class="w-8" src="assets/images/instruments/{{ instrument }}.png" />
{{ instrumentDisplay(instrument) }}
</a>
</li>
}
</ul>
</div>
<!-- Difficulty Dropdown -->
<div class="dropdown">
<label tabindex="0" class="btn btn-neutral rounded-btn rounded-l-none my-1">{{ difficultyDisplay(difficulty) }}</label>
<ul tabindex="0" class="menu dropdown-content z-[2] p-2 shadow bg-neutral text-neutral-content rounded-box w-40">
<li>
<a (click)="setDifficulty(null, $event)">{{ difficultyDisplay(null) }}</a>
</li>
@for (difficulty of difficulties; track $index) {
<li>
<a (click)="setDifficulty(difficulty, $event)">{{ difficultyDisplay(difficulty) }}</a>
</li>
}
</ul>
</div>
</div>
<!-- Advanced Search -->
<div class="order-6 md:order-6 xl:order-4 xl:flex-grow-[5]">
<button class="btn btn-ghost" (click)="setShowAdvanced(!showAdvanced)">
Advanced Search
<div class="cursor-pointer swap swap-rotate" [class.swap-active]="showAdvanced">
<i class="swap-off bi bi-chevron-down"></i>
<i class="swap-on bi bi-chevron-up"></i>
</div>
</button>
</div>
</div>
<div class="collapse-content justify-center">
<form [formGroup]="advancedSearchForm">
<div class="flex flex-wrap gap-5 justify-center">
<div>
<table class="table table-xs">
<thead>
<tr>
<th>
<div
class="tooltip tooltip-bottom font-normal [text-wrap:balance]"
data-tip='Search for text in these specific chart properties. Note: you can put a minus sign (-) before words to return only results without that word. (e.g. "Dragon -Dragonforce")'>
<span class="font-bold underline decoration-dotted cursor-help">Search by</span>
</div>
</th>
<th>
<div
class="tooltip tooltip-bottom font-normal [text-wrap:balance]"
data-tip="Only include results that match perfectly. (not case sensitive)">
<span class="font-bold underline decoration-dotted cursor-help">Exact</span>
</div>
</th>
<th>
<div class="tooltip tooltip-bottom font-normal [text-wrap:balance]" data-tip="Do not include results that match this.">
<span class="font-bold underline decoration-dotted cursor-help">Exclude</span>
</div>
</th>
</tr>
</thead>
<tbody>
<tr class="border-b-0" formGroupName="name">
<td><input type="text" placeholder="Name" class="input input-bordered input-sm" formControlName="value" /></td>
<td><input type="checkbox" class="checkbox" formControlName="exact" /></td>
<td><input type="checkbox" class="checkbox" formControlName="exclude" /></td>
</tr>
<tr class="border-b-0" formGroupName="artist">
<td><input type="text" placeholder="Artist" class="input input-bordered input-sm" formControlName="value" /></td>
<td><input type="checkbox" class="checkbox" formControlName="exact" /></td>
<td><input type="checkbox" class="checkbox" formControlName="exclude" /></td>
</tr>
<tr class="border-b-0" formGroupName="album">
<td><input type="text" placeholder="Album" class="input input-bordered input-sm" formControlName="value" /></td>
<td><input type="checkbox" class="checkbox" formControlName="exact" /></td>
<td><input type="checkbox" class="checkbox" formControlName="exclude" /></td>
</tr>
<tr class="border-b-0" formGroupName="genre">
<td><input type="text" placeholder="Genre" class="input input-bordered input-sm" formControlName="value" /></td>
<td><input type="checkbox" class="checkbox" formControlName="exact" /></td>
<td><input type="checkbox" class="checkbox" formControlName="exclude" /></td>
</tr>
<tr class="border-b-0" formGroupName="year">
<td><input type="text" placeholder="Year" class="input input-bordered input-sm" formControlName="value" /></td>
<td><input type="checkbox" class="checkbox" formControlName="exact" /></td>
<td><input type="checkbox" class="checkbox" formControlName="exclude" /></td>
</tr>
<tr class="border-b-0" formGroupName="charter">
<td><input type="text" placeholder="Charter" class="input input-bordered input-sm" formControlName="value" /></td>
<td><input type="checkbox" class="checkbox" formControlName="exact" /></td>
<td><input type="checkbox" class="checkbox" formControlName="exclude" /></td>
</tr>
</tbody>
</table>
</div>
<div class="flex flex-col gap-2 justify-end">
<table class="table table-xs">
<tbody>
<tr class="border-b-0">
<td class="text-sm">Length (minutes)</td>
<td>
<div class="join">
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minLength" />
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxLength" />
</div>
</td>
</tr>
<tr class="border-b-0">
<td class="text-sm">
<span
class="label-text underline decoration-dotted cursor-help tooltip [text-wrap:balance]"
data-tip="Also known as chart difficulty. Typically a number between 0 and 6.">
Intensity
</span>
</td>
<td>
<div class="join">
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minIntensity" />
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxIntensity" />
</div>
</td>
</tr>
<tr class="border-b-0">
<td class="text-sm">Average NPS</td>
<td>
<div class="join">
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minAverageNPS" />
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxAverageNPS" />
</div>
</td>
</tr>
<tr class="border-b-0">
<td class="text-sm">Max NPS</td>
<td>
<div class="join">
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minMaxNPS" />
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxMaxNPS" />
</div>
</td>
</tr>
<tr class="border-b-0">
<td class="text-sm">
<span
class="label-text underline decoration-dotted cursor-help tooltip [text-wrap:balance]"
data-tip="The date of the last time this chart was modified in Google Drive.">
Modified After
</span>
</td>
<td>
<input
type="date"
min="2012-01-01"
[max]="todayDate"
placeholder="YYYY/MM/DD"
class="input input-bordered join-item input-sm w-32"
formControlName="modifiedAfter"
(blur)="startValidation = true"
[class.input-error]="advancedSearchForm.invalid && startValidation" />
</td>
</tr>
<tr class="border-b-0">
<td class="text-sm">
<span
class="label-text underline decoration-dotted cursor-help tooltip [text-wrap:balance]"
data-tip="The MD5 hash of the chart folder or .sng file. You can enter multiple values if they are separated by commas.">
Hash
</span>
</td>
<td>
<input type="text" class="input input-bordered join-item input-sm w-32" formControlName="hash" />
</td>
</tr>
</tbody>
</table>
</div>
<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
#hasForcedNotes
type="checkbox"
class="toggle toggle-sm"
[indeterminate]="true"
(click)="clickCheckbox('hasForcedNotes', $event)" />
<span class="label-text" [class.text-opacity-70]="formValue('hasForcedNotes') === null">
{{ formValue('hasForcedNotes') === false ? 'No ' : '' }}Forced Notes
</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-normal gap-2">
<input
#hasOpenNotes
type="checkbox"
class="toggle toggle-sm"
[indeterminate]="true"
(click)="clickCheckbox('hasOpenNotes', $event)" />
<span class="label-text" [class.text-opacity-70]="formValue('hasOpenNotes') === null">
{{ formValue('hasOpenNotes') === false ? 'No ' : '' }}Open Notes
</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-normal gap-2">
<input
#hasTapNotes
type="checkbox"
class="toggle toggle-sm"
[indeterminate]="true"
(click)="clickCheckbox('hasTapNotes', $event)" />
<span class="label-text" [class.text-opacity-70]="formValue('hasTapNotes') === null">
{{ formValue('hasTapNotes') === false ? 'No ' : '' }}Tap Notes
</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)" />
<span class="label-text" [class.text-opacity-70]="formValue('hasLyrics') === null">
{{ formValue('hasLyrics') === false ? 'No ' : '' }}Lyrics
</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('hasVocals', $event)" />
<span class="label-text" [class.text-opacity-70]="formValue('hasVocals') === null">
{{ formValue('hasVocals') === false ? 'No ' : '' }}Vocals
</span>
</label>
</div>
</div>
<div class="flex flex-col">
<div class="form-control">
<label class="label cursor-pointer justify-normal gap-2">
<input
#hasRollLanes
type="checkbox"
class="toggle toggle-sm"
[indeterminate]="true"
(click)="clickCheckbox('hasRollLanes', $event)" />
<span class="label-text" [class.text-opacity-70]="formValue('hasRollLanes') === null">
{{ formValue('hasRollLanes') === false ? 'No ' : '' }}Roll Lanes
</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-normal gap-2">
<input #has2xKick type="checkbox" class="toggle toggle-sm" [indeterminate]="true" (click)="clickCheckbox('has2xKick', $event)" />
<span class="label-text" [class.text-opacity-70]="formValue('has2xKick') === null">
{{ formValue('has2xKick') === false ? 'No ' : '' }}2x Kick
</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('hasIssues', $event)" />
<span class="label-text" [class.text-opacity-70]="formValue('hasIssues') === null">
{{ formValue('hasIssues') === false ? 'No ' : '' }}Chart Issues
</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('hasVideoBackground', $event)" />
<span class="label-text" [class.text-opacity-70]="formValue('hasVideoBackground') === null">
{{ formValue('hasVideoBackground') === false ? 'No ' : '' }}Video Background
</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('modchart', $event)" />
<span class="label-text" [class.text-opacity-70]="formValue('modchart') === null">
{{ formValue('modchart') === false ? 'Not a ' : '' }}Modchart
</span>
</label>
</div>
</div>
</div>
<button class="btn btn-sm btn-primary" [class.btn-disabled]="advancedSearchForm.invalid && startValidation" (click)="searchAdvanced()">
Search{{ advancedSearchForm.invalid && startValidation ? ' ("Modified After" is invalid)' : '' }}
</button>
</div>
</div>
</form>
</div>
</div>

View File

@@ -1,51 +0,0 @@
#searchMenu {
flex-wrap: wrap;
margin-bottom: 0em;
border-radius: 0px;
}
#searchMenu>.item {
padding: .3em .4em;
min-height: inherit;
}
#searchMenu>.item:first-child {
box-sizing: content-box;
}
#searchIcon {
cursor: default;
box-sizing: border-box;
}
#advancedSearchForm {
margin: 0em;
border-width: 0px;
overflow: hidden;
transition: max-height 350ms cubic-bezier(0.45, 0, 0.55, 1);
max-height: 243.913px; /* This is its preferred height. Transition needs a static target number to work. */
}
.collapsed {
max-height: 0px !important;
}
#quantityDropdownItem, #similarityDropdownItem {
opacity: 1;
visibility: visible;
max-width: 100vw;
max-height: 100vh;
transition: visibility 0s, opacity 350ms cubic-bezier(0.45, 0, 0.55, 1);
}
.hidden {
opacity: 0 !important;
visibility: hidden !important;
max-width: 0px !important;
max-height: 0px !important;
transition:
opacity 350ms cubic-bezier(0.45, 0, 0.55, 1),
visibility 0s linear 350ms,
max-width 0s linear 350ms,
max-height 0s linear 350ms !important;
}

View File

@@ -1,78 +1,246 @@
import { AfterViewInit, Component, ElementRef, ViewChild } from '@angular/core'
import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
import { AbstractControl, FormBuilder, FormControl } from '@angular/forms'
import dayjs from 'dayjs'
import { distinctUntilChanged, switchMap, throttleTime } from 'rxjs'
import { Difficulty, Instrument } from 'scan-chart'
import { SearchService } from 'src-angular/app/core/services/search.service'
import { getDefaultSearch } from '../../../../../src-shared/interfaces/search.interface'
import { difficulties, difficultyDisplay, instrumentDisplay, instruments } from 'src-shared/UtilFunctions'
@Component({
selector: 'app-search-bar',
templateUrl: './search-bar.component.html',
styleUrls: ['./search-bar.component.scss'],
})
export class SearchBarComponent implements AfterViewInit {
export class SearchBarComponent implements OnInit, AfterViewInit {
@ViewChild('searchIcon', { static: true }) searchIcon: ElementRef
@ViewChild('quantityDropdown', { static: true }) quantityDropdown: ElementRef
@ViewChild('similarityDropdown', { static: true }) similarityDropdown: ElementRef
@ViewChild('diffSlider', { static: true }) diffSlider: ElementRef
@ViewChild('hasSoloSections') hasSoloSections: ElementRef<HTMLInputElement>
@ViewChild('hasForcedNotes') hasForcedNotes: ElementRef<HTMLInputElement>
@ViewChild('hasOpenNotes') hasOpenNotes: ElementRef<HTMLInputElement>
@ViewChild('hasTapNotes') hasTapNotes: ElementRef<HTMLInputElement>
@ViewChild('hasRollLanes') hasRollLanes: ElementRef<HTMLInputElement>
@ViewChild('has2xKick') has2xKick: ElementRef<HTMLInputElement>
isError = false
showAdvanced = false
searchSettings = getDefaultSearch()
private sliderInitialized = false
public showAdvanced = false
public instruments = instruments
public difficulties = difficulties
public instrumentDisplay = instrumentDisplay
public difficultyDisplay = difficultyDisplay
constructor(public searchService: SearchService) { }
public advancedSearchForm: ReturnType<this['getAdvancedSearchForm']>
public startValidation = false
constructor(
private searchService: SearchService,
private fb: FormBuilder,
) { }
ngOnInit() {
this.searchControl.valueChanges.pipe(
throttleTime(400, undefined, { leading: true, trailing: true }),
distinctUntilChanged(),
switchMap(search => this.searchService.search(search || '*'))
).subscribe()
this.initializeAdvancedSearchForm()
}
ngAfterViewInit() {
// TODO
// $(this.searchIcon.nativeElement).popup({
// onShow: () => this.isError, // Only show the popup if there is an error
// })
this.searchService.onSearchErrorStateUpdate(isError => {
this.isError = isError
this.updateDisabledControls()
this.searchService.instrument.valueChanges.subscribe(() => {
this.updateDisabledControls()
})
// $(this.quantityDropdown.nativeElement).dropdown({
// onChange: (value: string) => {
// this.searchSettings.quantity = value as 'all' | 'any'
// },
// })
// $(this.similarityDropdown.nativeElement).dropdown({
// onChange: (value: string) => {
// this.searchSettings.similarity = value as 'similar' | 'exact'
// },
// })
}
onSearch(query: string) {
this.searchSettings.query = query
this.searchSettings.limit = 50 + 1
this.searchSettings.offset = 0
this.searchService.newSearch(this.searchSettings)
}
onAdvancedSearchClick() {
this.showAdvanced = !this.showAdvanced
if (!this.sliderInitialized) {
setTimeout(() => { // Initialization requires this element to not be collapsed
// TODO
// $(this.diffSlider.nativeElement).slider({
// min: 0,
// max: 6,
// start: 0,
// end: 6,
// step: 1,
// onChange: (_length: number, min: number, max: number) => {
// this.searchSettings.minDiff = min
// this.searchSettings.maxDiff = max
// },
// })
}, 50)
this.sliderInitialized = true
setShowAdvanced(showAdvanced: boolean) {
this.showAdvanced = showAdvanced
if (showAdvanced) {
this.startValidation = false
this.searchControl.disable()
} else {
this.searchControl.enable()
}
}
isLoading() {
return this.searchService.isLoading()
get searchControl() {
return this.searchService.searchControl
}
get instrument() {
return this.searchService.instrument.value
}
setInstrument(instrument: Instrument | null, event: MouseEvent) {
this.searchService.instrument.setValue(instrument)
if (event.target instanceof HTMLElement) {
event.target.parentElement?.parentElement?.blur()
}
}
get difficulty() {
return this.searchService.difficulty.value
}
setDifficulty(difficulty: Difficulty | null, event: MouseEvent) {
this.searchService.difficulty.setValue(difficulty)
if (event.target instanceof HTMLElement) {
event.target.parentElement?.parentElement?.blur()
}
}
get logoType() {
switch (localStorage.getItem('theme')) {
case 'emerald': return 'emerald'
case 'halloween': return 'halloween'
case 'lemonade': return 'lemonade'
case 'night': return 'night'
case 'synthwave': return 'synthwave'
case 'aqua': return 'orange'
case 'valentine': return 'valentine'
case 'winter': return 'winter'
case 'aren': return 'aren'
case 'froogs': return 'froogs'
default: return 'default'
}
}
get todayDate() {
return dayjs().format('YYYY-MM-DD')
}
// TODO: run this when infinite scroll should happen
// @HostListener("window:scroll", [])
// onScroll(): void {
// if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) {
// if (this.searchService.areMorePages && !this.searchService.searchLoading) {
// this.searchService.search(this.searchControl.value || '*', true).subscribe()
// }
// }
// }
initializeAdvancedSearchForm() {
this.advancedSearchForm = this.getAdvancedSearchForm() as ReturnType<this['getAdvancedSearchForm']>
for (const key of ['name', 'artist', 'album', 'genre', 'year', 'charter'] as const) {
this.advancedSearchForm.get(key)?.get('exact')?.disable()
this.advancedSearchForm.get(key)?.get('exclude')?.disable()
this.advancedSearchForm.get(key)?.get('value')?.valueChanges.subscribe(value => {
if (value) {
this.advancedSearchForm.get(key)?.get('exact')?.enable()
this.advancedSearchForm.get(key)?.get('exclude')?.enable()
} else {
this.advancedSearchForm.get(key)?.get('exact')?.disable()
this.advancedSearchForm.get(key)?.get('exact')?.setValue(false)
this.advancedSearchForm.get(key)?.get('exclude')?.disable()
this.advancedSearchForm.get(key)?.get('exclude')?.setValue(false)
}
})
}
}
updateDisabledControls() {
const isDrums = this.searchService.instrument.value === 'drums'
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 : ''
this.hasRollLanes.nativeElement.title = !isDrums && !isAny ? explanation : ''
this.has2xKick.nativeElement.title = !isDrums && !isAny ? explanation : ''
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
} else {
this.advancedSearchForm.get('hasRollLanes')?.setValue(null)
this.advancedSearchForm.get('has2xKick')?.setValue(null)
this.hasRollLanes.nativeElement.indeterminate = true
this.has2xKick.nativeElement.indeterminate = true
}
}
}
getAdvancedSearchForm() {
return this.fb.group({
name: this.fb.nonNullable.group({ value: '', exact: false, exclude: false }),
artist: this.fb.nonNullable.group({ value: '', exact: false, exclude: false }),
album: this.fb.nonNullable.group({ value: '', exact: false, exclude: false }),
genre: this.fb.nonNullable.group({ value: '', exact: false, exclude: false }),
year: this.fb.nonNullable.group({ value: '', exact: false, exclude: false }),
charter: this.fb.nonNullable.group({ value: '', exact: false, exclude: false }),
minLength: null as number | null,
maxLength: null as number | null,
minIntensity: null as number | null,
maxIntensity: null as number | null,
minAverageNPS: null as number | null,
maxAverageNPS: null as number | null,
minMaxNPS: null as number | null,
maxMaxNPS: null as number | null,
modifiedAfter: this.fb.nonNullable.control('', { validators: dateVaidator }),
hash: this.fb.nonNullable.control(''),
hasSoloSections: null as boolean | null,
hasForcedNotes: null as boolean | null,
hasOpenNotes: null as boolean | null,
hasTapNotes: null as boolean | null,
hasLyrics: null as boolean | null,
hasVocals: null as boolean | null,
hasRollLanes: null as boolean | null,
has2xKick: null as boolean | null,
hasIssues: null as boolean | null,
hasVideoBackground: null as boolean | null,
modchart: null as boolean | null,
})
}
clickCheckbox(key: string, event: MouseEvent) {
if (event.target instanceof HTMLInputElement) {
const control = this.advancedSearchForm.get(key) as FormControl<boolean | null>
if (control.value === true) {
control.setValue(false)
event.target.checked = false
} else if (control.value === false) {
control.setValue(null)
event.target.checked = false
event.target.indeterminate = true
} else if (control.value === null) {
control.setValue(true)
event.target.checked = true
event.target.indeterminate = false
}
}
}
formValue(key: string) {
return this.advancedSearchForm.get(key)?.value
}
searchAdvanced() {
this.startValidation = true
if (this.advancedSearchForm.valid && !this.searchService.searchLoading) {
this.searchService.advancedSearch({
instrument: this.instrument,
difficulty: this.difficulty,
...this.advancedSearchForm.getRawValue(),
}).subscribe()
}
}
}
function dateVaidator(control: AbstractControl) {
if (control.value && isNaN(Date.parse(control.value))) {
return { 'dateVaidator': true }
}
return null
}

View File

@@ -1,5 +1,7 @@
<div id="bottomMenu" class="ui bottom borderless menu">
<div *ngIf="resultCount > 0" class="item">{{ resultCount }}{{ allResultsVisible ? '' : '+' }} Result{{ resultCount === 1 ? '' : 's' }}</div>
<div *ngIf="(searchService.groupedSongs?.length ?? 0) > 0" class="item">
{{ searchService.groupedSongs.length }}{{ allResultsVisible ? '' : '+' }} Result{{ searchService.groupedSongs.length === 1 ? '' : 's' }}
</div>
<div class="item">
<button *ngIf="selectedResults.length > 1" (click)="downloadSelected()" class="ui positive button">
Download {{ selectedResults.length }} Results

View File

@@ -1,6 +1,5 @@
import { ChangeDetectorRef, Component } from '@angular/core'
import { VersionResult } from '../../../../../src-shared/interfaces/songDetails.interface'
import { groupBy } from '../../../../../src-shared/UtilFunctions'
import { DownloadService } from '../../../core/services/download.service'
import { SearchService } from '../../../core/services/search.service'
@@ -13,17 +12,19 @@ import { SelectionService } from '../../../core/services/selection.service'
})
export class StatusBarComponent {
resultCount = 0
multipleCompleted = false
downloading = false
error = false
percent = 0
batchResults: VersionResult[]
chartGroups: VersionResult[][]
// TODO
// eslint-disable-next-line @typescript-eslint/no-explicit-any
batchResults: any[]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
chartGroups: any[][]
constructor(
private downloadService: DownloadService,
private searchService: SearchService,
public searchService: SearchService,
private selectionService: SelectionService,
ref: ChangeDetectorRef
) {
@@ -36,14 +37,10 @@ export class StatusBarComponent {
ref.detectChanges()
}, 0)
})
searchService.onSearchChanged(() => {
this.resultCount = searchService.resultCount
})
}
get allResultsVisible() {
return this.searchService.allResultsVisible
return false // this.searchService.allResultsVisible
}
get selectedResults() {
@@ -57,7 +54,8 @@ export class StatusBarComponent {
async downloadSelected() {
this.chartGroups = []
this.batchResults = await window.electron.invoke.getBatchSongDetails(this.selectedResults.map(result => result.id))
// TODO
// this.batchResults = await window.electron.invoke.getBatchSongDetails(this.selectedResults.map(result => result.id))
const versionGroups = groupBy(this.batchResults, 'songID')
for (const versionGroup of versionGroups) {
if (versionGroup.findIndex(version => version.chartID !== versionGroup[0].chartID) !== -1) {
@@ -68,7 +66,7 @@ export class StatusBarComponent {
if (this.chartGroups.length === 0) {
for (const versions of versionGroups) {
this.searchService.sortChart(versions)
// this.searchService.sortChart(versions)
const downloadVersion = versions[0]
const downloadSong = this.selectedResults.find(song => song.id === downloadVersion.songID)!
this.downloadService.addDownload(
@@ -89,7 +87,7 @@ export class StatusBarComponent {
downloadAllCharts() {
const songChartGroups = groupBy(this.batchResults, 'songID', 'chartID')
for (const chart of songChartGroups) {
this.searchService.sortChart(chart)
// this.searchService.sortChart(chart)
const downloadVersion = chart[0]
const downloadSong = this.selectedResults.find(song => song.id === downloadVersion.songID)!
this.downloadService.addDownload(

View File

@@ -1,116 +1,183 @@
import { HttpClient } from '@angular/common/http'
import { EventEmitter, Injectable } from '@angular/core'
import { FormControl } from '@angular/forms'
import { SongResult, SongSearch } from '../../../../src-shared/interfaces/search.interface'
import { VersionResult } from '../../../../src-shared/interfaces/songDetails.interface'
import { chain, xorBy } from 'lodash'
import { catchError, mergeMap, tap, throwError, timer } from 'rxjs'
import { Difficulty, Instrument } from 'scan-chart'
import { AdvancedSearch, ChartData, SearchResult } from 'src-shared/interfaces/search.interface'
@Injectable({
providedIn: 'root',
})
export class SearchService {
private resultsChangedEmitter = new EventEmitter<SongResult[]>() // For when any results change
private newResultsEmitter = new EventEmitter<SongResult[]>() // For when a new search happens
private errorStateEmitter = new EventEmitter<boolean>() // To indicate the search's error state
private results: SongResult[] = []
private awaitingResults = false
private currentQuery: SongSearch
private _allResultsVisible = true
public searchLoading = false
public songsResponse: Partial<SearchResult>
public currentPage = 1
public searchUpdated = new EventEmitter<Partial<SearchResult>>()
public isDefaultSearch = true
async newSearch(query: SongSearch) {
if (this.awaitingResults) { return }
this.awaitingResults = true
this.currentQuery = query
try {
this.results = this.trimLastChart(await window.electron.invoke.songSearch(this.currentQuery))
this.errorStateEmitter.emit(false)
} catch (err) {
this.results = []
console.log(err.message)
this.errorStateEmitter.emit(true)
}
this.awaitingResults = false
public groupedSongs: ChartData[][]
this.newResultsEmitter.emit(this.results)
this.resultsChangedEmitter.emit(this.results)
}
public availableIcons: string[]
isLoading() {
return this.awaitingResults
}
public searchControl = new FormControl('', { nonNullable: true })
public isSng: FormControl<boolean>
public instrument: FormControl<Instrument | null>
public difficulty: FormControl<Difficulty | null>
/**
* Event emitted when new search results are returned
* or when more results are added to an existing search.
* (emitted after `onNewSearch`)
*/
onSearchChanged(callback: (results: SongResult[]) => void) {
this.resultsChangedEmitter.subscribe(callback)
}
constructor(
private http: HttpClient,
) {
this.isSng = new FormControl<boolean>((localStorage.getItem('isSng') ?? 'true') === 'true', { nonNullable: true })
this.isSng.valueChanges.subscribe(isSng => localStorage.setItem('isSng', `${isSng}`))
/**
* Event emitted when a new search query is typed in.
* (emitted before `onSearchChanged`)
*/
onNewSearch(callback: (results: SongResult[]) => void) {
this.newResultsEmitter.subscribe(callback)
}
/**
* Event emitted when the error state of the search changes.
* (emitted before `onSearchChanged`)
*/
onSearchErrorStateUpdate(callback: (isError: boolean) => void) {
this.errorStateEmitter.subscribe(callback)
}
get resultCount() {
return this.results.length
}
async updateScroll() {
if (!this.awaitingResults && !this._allResultsVisible) {
this.awaitingResults = true
this.currentQuery.offset += 50
this.results.push(...this.trimLastChart(await window.electron.invoke.songSearch(this.currentQuery)))
this.awaitingResults = false
this.resultsChangedEmitter.emit(this.results)
}
}
trimLastChart(results: SongResult[]) {
if (results.length > 50) {
results.splice(50, 1)
this._allResultsVisible = false
} else {
this._allResultsVisible = true
}
return results
}
get allResultsVisible() {
return this._allResultsVisible
}
/**
* Orders `versionResults` by lastModified date, but prefer the
* non-pack version if it's only a few days older.
*/
sortChart(versionResults: VersionResult[]) {
const dates: { [versionID: number]: number } = {}
versionResults.forEach(version => dates[version.versionID] = new Date(version.lastModified).getTime())
versionResults.sort((v1, v2) => {
const diff = dates[v2.versionID] - dates[v1.versionID]
if (Math.abs(diff) < 6.048e+8 && v1.driveData.inChartPack !== v2.driveData.inChartPack) {
if (v1.driveData.inChartPack) {
return 1 // prioritize v2
} else {
return -1 // prioritize v1
}
} else {
return diff
this.instrument = new FormControl<Instrument>(
(localStorage.getItem('instrument') === 'null' ? null : localStorage.getItem('instrument')) as Instrument
)
this.instrument.valueChanges.subscribe(instrument => {
localStorage.setItem('instrument', `${instrument}`)
if (this.songsResponse.page) {
this.search(this.searchControl.value || '*').subscribe()
}
})
this.difficulty = new FormControl<Difficulty>(
(localStorage.getItem('difficulty') === 'null' ? null : localStorage.getItem('difficulty')) as Difficulty
)
this.difficulty.valueChanges.subscribe(difficulty => {
localStorage.setItem('difficulty', `${difficulty}`)
if (this.songsResponse.page) {
this.search(this.searchControl.value || '*').subscribe()
}
})
this.http.get<{ "name": string; "sha1": string }[]>('https://clonehero.gitlab.io/sources/icons.json').subscribe(result => {
this.availableIcons = result.map(r => r.name)
})
this.search().subscribe()
}
get areMorePages() { return this.songsResponse.page && this.groupedSongs.length === this.songsResponse.page * 20 }
/**
* General search, uses the `/search?q=` endpoint.
*
* If fetching the next page, set `nextPage=true` to incremement the page count in the search.
*
* Leave the search term blank to fetch the songs with charts most recently added.
*/
public search(search = '*', nextPage = false) {
this.searchLoading = true
this.isDefaultSearch = search === '*'
if (nextPage) {
this.currentPage++
} else {
this.currentPage = 1
}
let retries = 10
return this.http.post<SearchResult>(`/api/search`, {
search,
page: this.currentPage,
instrument: this.instrument.value,
difficulty: this.difficulty.value,
}).pipe(
catchError((err, caught) => {
if (err.status === 400 || retries-- <= 0) {
this.searchLoading = false
console.log(err)
return throwError(() => err)
} else {
return timer(2000).pipe(mergeMap(() => caught))
}
}),
tap(response => {
this.searchLoading = false
if (!nextPage) {
// Don't reload results if they are the same
if (this.groupedSongs && xorBy(this.songsResponse.data, response.data, r => r.chartId).length === 0) {
return
} else {
this.groupedSongs = []
}
}
this.songsResponse = response
this.groupedSongs.push(
...chain(response.data)
.groupBy(c => c.songId ?? -1 * c.chartId)
.values()
.value()
)
this.searchUpdated.emit(response)
})
)
}
public advancedSearch(search: AdvancedSearch) {
this.searchLoading = true
this.isDefaultSearch = false
let retries = 10
return this.http.post<{ data: SearchResult['data'] }>(`/api/search/advanced`, search).pipe(
catchError((err, caught) => {
if (err.status === 400 || retries-- <= 0) {
this.searchLoading = false
console.log(err)
return throwError(() => err)
} else {
return timer(2000).pipe(mergeMap(() => caught))
}
}),
tap(response => {
this.searchLoading = false
// Don't reload results if they are the same
if (this.groupedSongs && xorBy(this.songsResponse.data, response.data, r => r.chartId).length === 0) {
return
} else {
this.groupedSongs = []
}
this.songsResponse = response
this.groupedSongs.push(
...chain(response.data)
.groupBy(c => c.songId ?? -1 * c.chartId)
.values()
.value()
)
this.searchUpdated.emit(response)
})
)
}
}
// TODO: maybe use this, or delete it
/**
* Orders `versionResults` by lastModified date, but prefer the
* non-pack version if it's only a few days older.
*/
// sortChart(versionResults: VersionResult[]) {
// const dates: { [versionID: number]: number } = {}
// versionResults.forEach(version => dates[version.versionID] = new Date(version.lastModified).getTime())
// versionResults.sort((v1, v2) => {
// const diff = dates[v2.versionID] - dates[v1.versionID]
// if (Math.abs(diff) < 6.048e+8 && v1.driveData.inChartPack !== v2.driveData.inChartPack) {
// if (v1.driveData.inChartPack) {
// return 1 // prioritize v2
// } else {
// return -1 // prioritize v1
// }
// } else {
// return diff
// }
// })
// }

View File

@@ -1,6 +1,6 @@
import { EventEmitter, Injectable } from '@angular/core'
import { SongResult } from '../../../../src-shared/interfaces/search.interface'
import { SearchResult } from '../../../../src-shared/interfaces/search.interface'
import { SearchService } from './search.service'
// Note: this class prevents event cycles by only emitting events if the checkbox changes
@@ -10,7 +10,7 @@ import { SearchService } from './search.service'
})
export class SelectionService {
private searchResults: SongResult[] = []
private searchResults: Partial<SearchResult>
private selectAllChangedEmitter = new EventEmitter<boolean>()
private selectionChangedCallbacks: { [songID: number]: (selection: boolean) => void } = {}
@@ -19,14 +19,14 @@ export class SelectionService {
private selections: { [songID: number]: boolean | undefined } = {}
constructor(searchService: SearchService) {
searchService.onSearchChanged(results => {
searchService.searchUpdated.subscribe(results => {
this.searchResults = results
if (this.allSelected) {
this.selectAll() // Select newly added rows if allSelected
}
})
searchService.onNewSearch(results => {
searchService.searchUpdated.subscribe(results => {
this.searchResults = results
this.selectionChangedCallbacks = {}
this.selections = {}
@@ -35,7 +35,9 @@ export class SelectionService {
}
getSelectedResults() {
return this.searchResults.filter(result => this.selections[result.id] === true)
// TODO
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return [] as any[] // this.searchResults.filter(result => this.selections[result.id] === true)
}
onSelectAllChanged(callback: (selected: boolean) => void) {
@@ -57,7 +59,8 @@ export class SelectionService {
this.selectAllChangedEmitter.emit(false)
}
setTimeout(() => this.searchResults.forEach(result => this.deselectSong(result.id)), 0)
// TODO
// setTimeout(() => this.searchResults.forEach(result => this.deselectSong(result.id)), 0)
}
selectAll() {
@@ -66,7 +69,8 @@ export class SelectionService {
this.selectAllChangedEmitter.emit(true)
}
setTimeout(() => this.searchResults.forEach(result => this.selectSong(result.id)), 0)
// TODO
// setTimeout(() => this.searchResults.forEach(result => this.selectSong(result.id)), 0)
}
deselectSong(songID: number) {

View File

@@ -1,6 +1,8 @@
import { DOCUMENT } from '@angular/common'
import { Inject, Injectable } from '@angular/core'
import { Difficulty, Instrument } from 'scan-chart'
import { Settings, themes } from '../../../../src-shared/Settings'
@Injectable({
@@ -31,6 +33,22 @@ export class SettingsService {
this.document.documentElement.setAttribute('data-theme', theme)
}
get instrument() {
return this.settings.instrument
}
set instrument(newValue: Instrument | null) {
this.settings.instrument = newValue
this.saveSettings()
}
get difficulty() {
return this.settings.difficulty
}
set difficulty(newValue: Difficulty | null) {
this.settings.difficulty = newValue
this.saveSettings()
}
// Individual getters/setters
get libraryDirectory() {
return this.settings.libraryPath