Release Jarri Spooty Workspace v3.0.0
This commit is contained in:
@@ -10,6 +10,49 @@ This project follows a practical chronological changelog rather than autogenerat
|
|||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|
||||||
|
- Added Workspace tabs for Intake, Archive, and Diagnostics.
|
||||||
|
- Added per-tab frontend layout persistence with migration from the old single workspace layout into Intake.
|
||||||
|
- Added reset current tab and reset all tabs controls.
|
||||||
|
- Added Candidate Inspector Back/Clear selection action.
|
||||||
|
- Added Candidate Inspector selected candidate and honest alternative-state messaging.
|
||||||
|
- Added Archive Browser file-browser view with file icons, root summary, file count, total size, search, sort, and top-level folder grouping.
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- Kept Archive Browser read-only; no arbitrary browsing, delete, move, rename, or open-file operations were added.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3.0.0
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- Added ChronoGit-style draggable/resizable Workspace UI panels.
|
||||||
|
- Added Workspace localStorage persistence and reset layout control.
|
||||||
|
- Added Source Intake Archive destination field persisted in frontend localStorage.
|
||||||
|
- Added truthful Source Intake runtime messaging for `DOWNLOADS_PATH` and pending per-run destination routing.
|
||||||
|
- Added Archive Browser MVP with a refreshable file list.
|
||||||
|
- Added scoped read-only `GET /api/archive` listing for files inside the configured downloads root.
|
||||||
|
- Added Candidate Inspector MVP for selected track details.
|
||||||
|
- Added selected-track behavior in track rows.
|
||||||
|
- Added rejected YouTube candidate display when rejected URLs are available.
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- Bumped visible, root, backend, frontend, and lockfile package versions to 3.0.0.
|
||||||
|
- Kept existing playlist, track, retry, delete, Spotify OAuth, queue, download, and yt-dlp behavior intact.
|
||||||
|
|
||||||
|
## Pending
|
||||||
|
|
||||||
|
- Per-run archive destination routing is still pending backend support.
|
||||||
|
- Candidate scoring history is not exposed yet; the inspector shows current stored track state and rejected URLs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2.4.2 Hardened Workspace Foundation
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
### Spotify
|
### Spotify
|
||||||
|
|
||||||
- Added Spotify OAuth login flow
|
- Added Spotify OAuth login flow
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ It retrieves metadata from Spotify and locates matching audio on YouTube.
|
|||||||
|
|
||||||
Deterministic Spotify metadata ingestion with duration-aware YouTube candidate scoring, paced queue execution, hardened Docker deployment, and local archival workflows.
|
Deterministic Spotify metadata ingestion with duration-aware YouTube candidate scoring, paced queue execution, hardened Docker deployment, and local archival workflows.
|
||||||
|
|
||||||
|
Current version: 3.0.0
|
||||||
|
|
||||||
This hardened branch focuses on:
|
This hardened branch focuses on:
|
||||||
|
|
||||||
|
- ChronoGit-style Workspace UI panels
|
||||||
- Large playlist support (>100 tracks)
|
- Large playlist support (>100 tracks)
|
||||||
- Spotify OAuth login flow
|
- Spotify OAuth login flow
|
||||||
- Better YouTube pacing and throttling resistance
|
- Better YouTube pacing and throttling resistance
|
||||||
@@ -27,6 +30,10 @@ This hardened branch focuses on:
|
|||||||
- Improved operational observability
|
- Improved operational observability
|
||||||
- Reduced unused dependency surface
|
- Reduced unused dependency surface
|
||||||
- Hardened filename, cover-art, subprocess, and websocket boundaries
|
- Hardened filename, cover-art, subprocess, and websocket boundaries
|
||||||
|
- Workspace Archive Browser MVP with scoped read-only archive listing
|
||||||
|
- Workspace Candidate Inspector MVP for selected track details
|
||||||
|
- Frontend archive destination field for planned per-run routing
|
||||||
|
- Frontend Workspace tabs for Intake, Archive, and Diagnostics layouts
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -48,6 +55,45 @@ This hardened branch focuses on:
|
|||||||
- Deterministic yt-dlp error classification
|
- Deterministic yt-dlp error classification
|
||||||
- Hardened cover-art validation and embedding
|
- Hardened cover-art validation and embedding
|
||||||
- Explicit client-facing websocket payload shaping
|
- Explicit client-facing websocket payload shaping
|
||||||
|
- Draggable/resizable Workspace UI
|
||||||
|
- Archive Browser panel for downloaded file inspection
|
||||||
|
- Candidate Inspector panel for selected track diagnostics
|
||||||
|
- Frontend-persisted archive destination input
|
||||||
|
- Per-tab workspace layout presets
|
||||||
|
- Read-only archive search, sort, and grouped file inspection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Workspace UI
|
||||||
|
|
||||||
|
Jarri Spooty 3.0.0 introduces a draggable/resizable Workspace shell with frontend tab presets.
|
||||||
|
|
||||||
|
Workspace tabs:
|
||||||
|
|
||||||
|
- Intake
|
||||||
|
- Archive
|
||||||
|
- Diagnostics
|
||||||
|
|
||||||
|
Each tab has its own persisted panel layout in browser localStorage. The old single workspace layout is migrated into the Intake tab when possible. The current tab can be reset independently, and all tabs can be reset together.
|
||||||
|
|
||||||
|
Workspace panels:
|
||||||
|
|
||||||
|
- Source Intake: Spotify connection, URL queueing, and a frontend-persisted Archive destination field
|
||||||
|
- Queue Observatory: Spotify, playlist, single-song, and archive-run metrics
|
||||||
|
- Playlist History: existing playlist rows and delete completed/delete failed controls
|
||||||
|
- Single Songs: existing single-track queue rows
|
||||||
|
- Archive Browser: read-only browser-style listing of downloaded files inside the configured downloads root, with search, sort, file count, total size, and top-level folder grouping
|
||||||
|
- Candidate Inspector: selected track details including Spotify URL, selected YouTube candidate, status, error, retry attempts, rejected candidates when available, and a Back/Clear selection action
|
||||||
|
|
||||||
|
The Archive destination field is frontend-persisted planning state. Runtime downloads still use the backend `DOWNLOADS_PATH` root. Per-run destination routing is pending backend support.
|
||||||
|
|
||||||
|
The Archive Browser uses:
|
||||||
|
|
||||||
|
GET /api/archive
|
||||||
|
|
||||||
|
It returns the configured downloads root and files under that root only. It does not allow arbitrary filesystem browsing, deletion, moving, renaming, or path traversal.
|
||||||
|
|
||||||
|
Candidate alternatives are limited to the selected YouTube URL and rejected YouTube URLs already stored for the track. Full scoring history is not persisted yet.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Generated
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "spooty",
|
"name": "spooty",
|
||||||
"version": "2.4.2",
|
"version": "3.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "spooty",
|
"name": "spooty",
|
||||||
"version": "2.4.2",
|
"version": "3.0.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"src/backend",
|
"src/backend",
|
||||||
"src/frontend"
|
"src/frontend"
|
||||||
@@ -25508,7 +25508,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"src/backend": {
|
"src/backend": {
|
||||||
"version": "2.4.2",
|
"version": "3.0.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/bullmq": "^10.2.3",
|
"@nestjs/bullmq": "^10.2.3",
|
||||||
@@ -25571,7 +25571,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"src/frontend": {
|
"src/frontend": {
|
||||||
"version": "2.4.2",
|
"version": "3.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^19.2.22",
|
"@angular/animations": "^19.2.22",
|
||||||
"@angular/common": "^19.2.22",
|
"@angular/common": "^19.2.22",
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "2.4.2",
|
"version": "3.0.0",
|
||||||
"name": "spooty",
|
"name": "spooty",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"src/backend",
|
"src/backend",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "2.4.2",
|
"version": "3.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { BullModule } from '@nestjs/bullmq';
|
|||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { AuthGuard } from './shared/auth.guard';
|
import { AuthGuard } from './shared/auth.guard';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
import { ArchiveModule } from './archive/archive.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -79,6 +80,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|||||||
}),
|
}),
|
||||||
TrackModule,
|
TrackModule,
|
||||||
PlaylistModule,
|
PlaylistModule,
|
||||||
|
ArchiveModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ArchiveListingDto, ArchiveService } from './archive.service';
|
||||||
|
|
||||||
|
@Controller('archive')
|
||||||
|
export class ArchiveController {
|
||||||
|
constructor(private readonly archiveService: ArchiveService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(): Promise<ArchiveListingDto> {
|
||||||
|
return this.archiveService.list();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SharedModule } from '../shared/shared.module';
|
||||||
|
import { ArchiveController } from './archive.controller';
|
||||||
|
import { ArchiveService } from './archive.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [SharedModule],
|
||||||
|
controllers: [ArchiveController],
|
||||||
|
providers: [ArchiveService],
|
||||||
|
})
|
||||||
|
export class ArchiveModule {}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import { relative, resolve } from 'path';
|
||||||
|
import { UtilsService } from '../shared/utils.service';
|
||||||
|
|
||||||
|
export interface ArchiveFileDto {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
modifiedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchiveListingDto {
|
||||||
|
root: string;
|
||||||
|
files: ArchiveFileDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ArchiveService {
|
||||||
|
constructor(private readonly utilsService: UtilsService) {}
|
||||||
|
|
||||||
|
async list(): Promise<ArchiveListingDto> {
|
||||||
|
const root = this.utilsService.getRootDownloadsPath();
|
||||||
|
const files = await this.listFiles(root, root);
|
||||||
|
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
files: files.sort((a, b) => b.modifiedAt - a.modifiedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listFiles(root: string, currentPath: string): Promise<ArchiveFileDto[]> {
|
||||||
|
const entries = await fs.readdir(currentPath, { withFileTypes: true });
|
||||||
|
const files: ArchiveFileDto[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = this.utilsService.ensureInsideDownloadsRoot(
|
||||||
|
resolve(currentPath, entry.name),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...await this.listFiles(root, entryPath));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = await fs.stat(entryPath);
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
name: entry.name,
|
||||||
|
path: relative(root, entryPath).split(/[\\/]+/).join('/'),
|
||||||
|
sizeBytes: stat.size,
|
||||||
|
modifiedAt: stat.mtimeMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,10 @@ enum WsTrackOperation {
|
|||||||
Delete = 'trackDelete',
|
Delete = 'trackDelete',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClientTrack = Omit<Partial<TrackEntity>, 'rejectedYoutubeUrls'> & {
|
||||||
|
rejectedYoutubeUrls?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
@WebSocketGateway()
|
@WebSocketGateway()
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TrackService {
|
export class TrackService {
|
||||||
@@ -56,13 +60,14 @@ export class TrackService {
|
|||||||
this.io.emit(WsTrackOperation.Delete, { id });
|
this.io.emit(WsTrackOperation.Delete, { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
private toClientTrack(track: TrackEntity): Partial<TrackEntity> {
|
private toClientTrack(track: TrackEntity): ClientTrack {
|
||||||
return {
|
return {
|
||||||
id: track.id,
|
id: track.id,
|
||||||
artist: track.artist,
|
artist: track.artist,
|
||||||
name: track.name,
|
name: track.name,
|
||||||
spotifyUrl: track.spotifyUrl,
|
spotifyUrl: track.spotifyUrl,
|
||||||
youtubeUrl: track.youtubeUrl,
|
youtubeUrl: track.youtubeUrl,
|
||||||
|
rejectedYoutubeUrls: this.parseRejectedYoutubeUrls(track),
|
||||||
downloadAttemptCount: track.downloadAttemptCount,
|
downloadAttemptCount: track.downloadAttemptCount,
|
||||||
status: track.status,
|
status: track.status,
|
||||||
error: track.error,
|
error: track.error,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.4.2",
|
"version": "3.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve --proxy-config proxy.conf.json",
|
"start": "ng serve --proxy-config proxy.conf.json",
|
||||||
|
|||||||
@@ -1,94 +1,322 @@
|
|||||||
<section class="jarri-hero">
|
<main class="workspace-shell">
|
||||||
<div class="hero-left">
|
<header class="workspace-titlebar">
|
||||||
<p class="eyebrow">Jarri subsystem · deterministic media ingestion</p>
|
<div class="brand-stack">
|
||||||
<h1>
|
<div class="brand-line">
|
||||||
<i class="fa-brands fa-spotify"></i>
|
<i class="fa-brands fa-spotify"></i>
|
||||||
Jarri Spooty
|
<h1>Jarri Spooty</h1>
|
||||||
<span class="tag version-tag">v{{version}}</span>
|
<span class="tag version-tag">v{{version}}</span>
|
||||||
</h1>
|
</div>
|
||||||
<p class="hero-subtitle">Spotify metadata → YouTube candidate scoring → Local archive</p>
|
<p>Spotify metadata → YouTube candidate scoring → Local archive</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-right">
|
<div class="truth-strip">
|
||||||
|
<span class="truth-pill" [class.connected]="spotifyConnected">
|
||||||
|
<i class="fa-solid" [ngClass]="spotifyConnected ? 'fa-circle-check' : 'fa-plug'"></i>
|
||||||
|
{{ spotifyConnected ? 'Spotify connected' : 'Spotify disconnected' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="workspace-tabs" aria-label="Workspace tabs">
|
||||||
<button
|
<button
|
||||||
class="button auth-button"
|
*ngFor="let tab of workspaceTabs"
|
||||||
[class.connected]="spotifyConnected"
|
class="workspace-tab"
|
||||||
(click)="connectSpotify()"
|
type="button"
|
||||||
|
[class.active]="activeWorkspaceTab === tab"
|
||||||
|
(click)="switchWorkspaceTab(tab)"
|
||||||
>
|
>
|
||||||
<i class="fa-solid" [ngClass]="spotifyConnected ? 'fa-circle-check' : 'fa-plug'"></i>
|
{{ getWorkspaceTabLabel(tab) }}
|
||||||
{{ spotifyConnected ? 'Spotify connected' : 'Connect Spotify' }}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</nav>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="jarri-body">
|
<section class="workspace-controls">
|
||||||
<div class="intelligence-grid">
|
<div class="panel-picker">
|
||||||
<div class="intel-card">
|
<div class="select">
|
||||||
<span class="intel-label">Spotify</span>
|
<select [(ngModel)]="selectedPanelType" aria-label="Add panel type">
|
||||||
<strong [class.online]="spotifyConnected">{{ spotifyConnected ? 'Connected' : 'Disconnected' }}</strong>
|
<option *ngFor="let panelType of panelTypes" [ngValue]="panelType">
|
||||||
|
{{ getPanelTitle(panelType) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="button is-primary" type="button" (click)="addPanel()">
|
||||||
|
<i class="fa-solid fa-plus"></i>
|
||||||
|
Add Panel
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="intel-card">
|
|
||||||
<span class="intel-label">Matching</span>
|
|
||||||
<strong>Duration-aware</strong>
|
|
||||||
</div>
|
|
||||||
<div class="intel-card">
|
|
||||||
<span class="intel-label">Queue</span>
|
|
||||||
<strong>Paced</strong>
|
|
||||||
</div>
|
|
||||||
<div class="intel-card">
|
|
||||||
<span class="intel-label">Mode</span>
|
|
||||||
<strong>Archive run</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="box jarri-box">
|
<div class="panel-picker">
|
||||||
<p class="subtitle">Acquire music</p>
|
<button class="button reset-button" type="button" (click)="resetLayout()">
|
||||||
<div class="is-flex">
|
<i class="fa-solid fa-rotate-left"></i>
|
||||||
<input class="input"
|
Reset Current Tab
|
||||||
[class.is-danger]="url && !isValidSpotifyUrl"
|
</button>
|
||||||
type="text"
|
<button class="button reset-button" type="button" (click)="resetAllLayouts()">
|
||||||
[(ngModel)]="url"
|
<i class="fa-solid fa-rotate"></i>
|
||||||
placeholder="Paste Spotify playlist or track URL"/>
|
Reset All Tabs
|
||||||
<button class="button is-primary"
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="workspace-canvas" aria-label="Jarri Spooty workspace">
|
||||||
|
<article
|
||||||
|
*ngFor="let panel of workspace.panels; trackBy: renderPanel"
|
||||||
|
class="workspace-panel"
|
||||||
|
[ngClass]="'panel-' + panel.type"
|
||||||
|
[ngStyle]="panelStyle(panel)"
|
||||||
|
>
|
||||||
|
<header class="panel-header" (pointerdown)="beginDrag($event, panel)">
|
||||||
|
<div class="panel-title">
|
||||||
|
<span class="status-light"></span>
|
||||||
|
<strong>{{ panel.title }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="panel-close"
|
||||||
|
type="button"
|
||||||
|
title="Close panel"
|
||||||
|
[disabled]="workspace.panels.length <= 1"
|
||||||
|
(pointerdown)="$event.stopPropagation()"
|
||||||
|
(click)="closePanel(panel.id)"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="panel-content" [ngSwitch]="panel.type">
|
||||||
|
<section *ngSwitchCase="'source-intake'" class="source-intake-panel">
|
||||||
|
<button
|
||||||
|
class="button auth-button"
|
||||||
|
[class.connected]="spotifyConnected"
|
||||||
|
(click)="connectSpotify()"
|
||||||
|
>
|
||||||
|
<i class="fa-solid" [ngClass]="spotifyConnected ? 'fa-circle-check' : 'fa-plug'"></i>
|
||||||
|
{{ spotifyConnected ? 'Spotify connected' : 'Connect Spotify' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="intake-form">
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
[class.is-danger]="url && !isValidSpotifyUrl"
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="url"
|
||||||
|
placeholder="Paste Spotify playlist or track URL"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="button is-primary queue-button"
|
||||||
[class.is-loading]="(createLoading$ | async)?.isLoading"
|
[class.is-loading]="(createLoading$ | async)?.isLoading"
|
||||||
(click)="download()"
|
(click)="download()"
|
||||||
[disabled]="!url || !isValidSpotifyUrl"
|
[disabled]="!url || !isValidSpotifyUrl"
|
||||||
>
|
>
|
||||||
<i class="fa-solid fa-download"></i> Queue
|
<i class="fa-solid fa-download"></i>
|
||||||
</button>
|
Queue
|
||||||
</div>
|
</button>
|
||||||
<p *ngIf="url && !isValidSpotifyUrl" class="help is-danger">
|
</div>
|
||||||
Please enter a valid Spotify URL.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="box jarri-box">
|
<label class="field-label">Archive destination</label>
|
||||||
<div class="is-flex is-justify-content-space-between">
|
<input
|
||||||
<div>
|
class="input"
|
||||||
<p class="subtitle">Playlist History</p>
|
type="text"
|
||||||
<p class="has-text-grey is-size-7">One collapsed row per playlist run.</p>
|
[(ngModel)]="archiveDestination"
|
||||||
|
(ngModelChange)="saveArchiveDestination()"
|
||||||
|
placeholder="Frontend-planned archive destination"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="truth-note">
|
||||||
|
<p>Runtime download root: {{ archiveListing?.root || archiveDestination || 'loading...' }}</p>
|
||||||
|
<p>Per-run destination routing: pending backend support</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p *ngIf="url && !isValidSpotifyUrl" class="help is-danger">
|
||||||
|
Please enter a valid Spotify URL.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section *ngSwitchCase="'queue-observatory'" class="metrics-panel">
|
||||||
|
<ng-container *ngIf="playlists$ | async as playlists">
|
||||||
|
<ng-container *ngIf="songs$ | async as songs">
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>Spotify</span>
|
||||||
|
<strong [class.online]="spotifyConnected">{{ spotifyConnected ? 'Connected' : 'Disconnected' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>Playlists</span>
|
||||||
|
<strong>{{ playlists.length }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>Single Songs</span>
|
||||||
|
<strong>{{ songs.length }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>Mode</span>
|
||||||
|
<strong>Archive run</strong>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section *ngSwitchCase="'playlist-history'" class="history-panel">
|
||||||
|
<div class="section-toolbar">
|
||||||
|
<div>
|
||||||
|
<h2>Playlist History</h2>
|
||||||
|
<p>One collapsed row per playlist run.</p>
|
||||||
|
</div>
|
||||||
|
<div class="buttons has-addons">
|
||||||
|
<button class="button is-outlined is-success" title="Remove completed from list" (click)="deleteCompleted()">
|
||||||
|
<i class="fa-solid fa-check"></i>
|
||||||
|
</button>
|
||||||
|
<button class="button is-outlined is-danger" title="Remove failed from list" (click)="deleteFailed()">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-container *ngIf="playlists$ | async as playlists">
|
||||||
|
<app-playlist-box *ngFor="let playlist of playlists" [playlist]="playlist"></app-playlist-box>
|
||||||
|
<p *ngIf="playlists?.length === 0" class="empty-state">No playlists</p>
|
||||||
|
</ng-container>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section *ngSwitchCase="'single-songs'" class="history-panel">
|
||||||
|
<div class="section-toolbar">
|
||||||
|
<div>
|
||||||
|
<h2>Single Songs</h2>
|
||||||
|
<p>Direct Spotify track queue entries.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-container *ngIf="songs$ | async as songs">
|
||||||
|
<app-playlist-box *ngFor="let playlist of songs" [playlist]="playlist"></app-playlist-box>
|
||||||
|
<p *ngIf="songs?.length === 0" class="empty-state">No songs</p>
|
||||||
|
</ng-container>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section *ngSwitchCase="'archive-browser'" class="history-panel">
|
||||||
|
<div class="section-toolbar">
|
||||||
|
<div>
|
||||||
|
<h2>Archive Browser</h2>
|
||||||
|
<p>Root: {{ archiveListing?.root || 'loading...' }}</p>
|
||||||
|
</div>
|
||||||
|
<button class="button is-small reset-button" type="button" [class.is-loading]="archiveLoading" (click)="refreshArchive()">
|
||||||
|
<i class="fa-solid fa-rotate"></i>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p *ngIf="archiveError" class="help is-danger">{{ archiveError }}</p>
|
||||||
|
<ng-container *ngIf="archiveListing as archive">
|
||||||
|
<div class="archive-summary">
|
||||||
|
<div>
|
||||||
|
<strong>{{ archiveFileCount() }}</strong>
|
||||||
|
<span>files</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{{ formatBytes(archiveTotalBytes()) }}</strong>
|
||||||
|
<span>total</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Read-only</strong>
|
||||||
|
<span>inspection</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="archive-controls">
|
||||||
|
<input
|
||||||
|
class="input is-small"
|
||||||
|
type="search"
|
||||||
|
[(ngModel)]="archiveSearch"
|
||||||
|
placeholder="Search archive"
|
||||||
|
/>
|
||||||
|
<div class="select is-small">
|
||||||
|
<select [(ngModel)]="archiveSort" aria-label="Sort archive files">
|
||||||
|
<option *ngFor="let sortMode of archiveSortModes" [ngValue]="sortMode">
|
||||||
|
{{ sortMode }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p *ngIf="archiveFileCount() === 0" class="empty-state">No downloaded files found.</p>
|
||||||
|
|
||||||
|
<section *ngFor="let group of archiveGroups()" class="archive-group">
|
||||||
|
<h3>
|
||||||
|
<i class="fa-solid fa-folder"></i>
|
||||||
|
{{ group.folder }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div *ngFor="let file of group.files" class="archive-file-card">
|
||||||
|
<i class="fa-solid" [ngClass]="hasFolder(file) ? 'fa-folder-tree' : 'fa-file-audio'"></i>
|
||||||
|
<div>
|
||||||
|
<strong>{{ file.name }}</strong>
|
||||||
|
<span>{{ folderPath(file) }}</span>
|
||||||
|
</div>
|
||||||
|
<small>{{ formatBytes(file.sizeBytes) }}</small>
|
||||||
|
<small>{{ formatModifiedAt(file.modifiedAt) }}</small>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</ng-container>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section *ngSwitchCase="'candidate-inspector'" class="history-panel">
|
||||||
|
<ng-container *ngIf="selectedTrack$ | async as selectedTrack; else noTrackSelected">
|
||||||
|
<div class="section-toolbar">
|
||||||
|
<div>
|
||||||
|
<h2>{{ selectedTrack.artist }} - {{ selectedTrack.name }}</h2>
|
||||||
|
<p>Status: {{ trackStatusLabel(selectedTrack.status) }}</p>
|
||||||
|
</div>
|
||||||
|
<button class="button is-small reset-button" type="button" (click)="clearSelectedTrack()">
|
||||||
|
<i class="fa-solid fa-arrow-left"></i>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="inspector-list">
|
||||||
|
<dt>Spotify URL</dt>
|
||||||
|
<dd><a [href]="selectedTrack.spotifyUrl" target="_blank">{{ selectedTrack.spotifyUrl || 'none' }}</a></dd>
|
||||||
|
<dt>Status</dt>
|
||||||
|
<dd>{{ trackStatusLabel(selectedTrack.status) }}</dd>
|
||||||
|
<dt>Download attempts</dt>
|
||||||
|
<dd>{{ selectedTrack.downloadAttemptCount || 0 }}</dd>
|
||||||
|
<dt>Error</dt>
|
||||||
|
<dd>{{ selectedTrack.error || 'none' }}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="candidate-options">
|
||||||
|
<strong>Other options</strong>
|
||||||
|
<p *ngIf="selectedTrack.youtubeUrl">
|
||||||
|
Selected candidate:
|
||||||
|
<a [href]="selectedTrack.youtubeUrl" target="_blank">{{ selectedTrack.youtubeUrl }}</a>
|
||||||
|
</p>
|
||||||
|
<p>Rejected candidates: {{ rejectedUrls(selectedTrack).length }}</p>
|
||||||
|
<a *ngFor="let url of rejectedUrls(selectedTrack)" [href]="url" target="_blank">{{ url }}</a>
|
||||||
|
<p *ngIf="!selectedTrack.youtubeUrl && rejectedUrls(selectedTrack).length === 0" class="empty-state">
|
||||||
|
Candidate alternatives are not persisted yet. Future scoring history will appear here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-template #noTrackSelected>
|
||||||
|
<div class="placeholder-panel">
|
||||||
|
<i class="fa-solid fa-magnifying-glass-chart"></i>
|
||||||
|
<p>Select a track row inside an expanded playlist to inspect Spotify, YouTube, retry, and error details.</p>
|
||||||
|
</div>
|
||||||
|
<ng-container *ngIf="allTracks$ | async as allTracks">
|
||||||
|
<button
|
||||||
|
*ngFor="let track of allTracks"
|
||||||
|
class="button is-small is-fullwidth reset-button"
|
||||||
|
type="button"
|
||||||
|
(click)="selectTrack(track)"
|
||||||
|
>
|
||||||
|
{{ track.artist }} - {{ track.name }}
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
</ng-template>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div class="buttons has-addons">
|
|
||||||
<button class="button is-outlined is-success" title="Remove completed from list" (click)="deleteCompleted()">
|
|
||||||
<i class="fa-solid fa-check"></i>
|
|
||||||
</button>
|
|
||||||
<button class="button is-outlined is-danger" title="Remove failed from list" (click)="deleteFailed()">
|
|
||||||
<i class="fa-solid fa-xmark"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ng-container *ngIf="playlists$ | async as playlists">
|
<span
|
||||||
<app-playlist-box *ngFor="let playlist of playlists" [playlist]="playlist"></app-playlist-box>
|
class="resize-handle"
|
||||||
<p *ngIf="playlists?.length === 0" class="has-text-grey has-text-centered">No playlists</p>
|
title="Resize panel"
|
||||||
</ng-container>
|
(pointerdown)="beginResize($event, panel)"
|
||||||
</div>
|
></span>
|
||||||
|
</article>
|
||||||
<div class="box jarri-box">
|
</section>
|
||||||
<p class="subtitle">Single Songs</p>
|
</main>
|
||||||
<ng-container *ngIf="songs$ | async as songs">
|
|
||||||
<app-playlist-box *ngFor="let playlist of songs" [playlist]="playlist"></app-playlist-box>
|
|
||||||
<p *ngIf="songs?.length === 0" class="has-text-grey has-text-centered">No songs</p>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|||||||
@@ -1,129 +1,324 @@
|
|||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background:
|
background: #0d1515;
|
||||||
radial-gradient(circle at top left, rgba(29, 185, 84, 0.18), transparent 30rem),
|
|
||||||
linear-gradient(135deg, #06110c 0%, #0a1210 45%, #020403 100%);
|
|
||||||
color: #eafff0;
|
color: #eafff0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.jarri-hero {
|
.workspace-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-titlebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
align-items: center;
|
min-height: 76px;
|
||||||
padding: 34px 44px;
|
padding: 14px 24px;
|
||||||
background:
|
border-bottom: 1px solid rgba(126, 255, 180, 0.22);
|
||||||
linear-gradient(90deg, rgba(3, 12, 7, 0.95), rgba(29, 185, 84, 0.92));
|
background: rgba(5, 11, 10, 0.96);
|
||||||
border-bottom: 1px solid rgba(126, 255, 180, 0.25);
|
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.26);
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.brand-line {
|
||||||
margin: 0 0 6px;
|
display: flex;
|
||||||
color: #9fffc3;
|
align-items: center;
|
||||||
text-transform: uppercase;
|
gap: 10px;
|
||||||
letter-spacing: 0.18em;
|
}
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 800;
|
h1,
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0;
|
|
||||||
color: #f3fff7;
|
color: #f3fff7;
|
||||||
font-size: clamp(2.2rem, 4vw, 4.6rem);
|
font-size: 1.35rem;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
letter-spacing: -0.06em;
|
|
||||||
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.45);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-subtitle {
|
.brand-stack p {
|
||||||
margin-top: 8px;
|
margin-top: 4px;
|
||||||
color: #d8ffe5;
|
color: #bcd1c4;
|
||||||
font-size: 1.05rem;
|
font-size: 0.86rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-tag {
|
.version-tag {
|
||||||
background: #07110c;
|
background: #12221a;
|
||||||
color: #9fffc3;
|
color: #9fffc3;
|
||||||
vertical-align: middle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-button {
|
.truth-pill {
|
||||||
border-radius: 999px;
|
display: inline-flex;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
align-items: center;
|
||||||
background: rgba(255, 255, 255, 0.92);
|
gap: 8px;
|
||||||
color: #07110c;
|
min-height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid rgba(255, 214, 102, 0.35);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 214, 102, 0.12);
|
||||||
|
color: #ffe3a3;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-button.connected {
|
.truth-pill.connected {
|
||||||
|
border-color: rgba(159, 255, 195, 0.45);
|
||||||
|
background: rgba(159, 255, 195, 0.14);
|
||||||
|
color: #9fffc3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-controls {
|
||||||
|
position: sticky;
|
||||||
|
top: 121px;
|
||||||
|
z-index: 18;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-bottom: 1px solid rgba(126, 255, 180, 0.12);
|
||||||
|
background: rgba(8, 15, 15, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-picker {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-button {
|
||||||
|
background: #17211f;
|
||||||
|
border-color: rgba(188, 209, 196, 0.28);
|
||||||
|
color: #dceee4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-canvas {
|
||||||
|
position: relative;
|
||||||
|
width: 1848px;
|
||||||
|
min-width: 100%;
|
||||||
|
height: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-panel {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(126, 255, 180, 0.2);
|
||||||
|
background: rgba(13, 22, 21, 0.96);
|
||||||
|
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 10px 0 12px;
|
||||||
|
border-bottom: 1px solid rgba(126, 255, 180, 0.16);
|
||||||
|
background: #14211d;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #effff4;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-light {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
background: #9fffc3;
|
background: #9fffc3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.jarri-body {
|
.panel-close {
|
||||||
padding: 34px 44px;
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid rgba(188, 209, 196, 0.24);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: #dceee4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.intelligence-grid {
|
.panel-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle::after {
|
||||||
|
position: absolute;
|
||||||
|
right: 5px;
|
||||||
|
bottom: 5px;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-right: 2px solid rgba(159, 255, 195, 0.7);
|
||||||
|
border-bottom: 2px solid rgba(159, 255, 195, 0.7);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-intake-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 14px;
|
|
||||||
margin-bottom: 22px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.intel-card {
|
.auth-button {
|
||||||
|
width: max-content;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: #07110c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intake-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-button {
|
||||||
|
min-width: 112px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
padding: 14px;
|
||||||
border: 1px solid rgba(126, 255, 180, 0.18);
|
border: 1px solid rgba(126, 255, 180, 0.18);
|
||||||
border-radius: 16px;
|
border-radius: 8px;
|
||||||
padding: 14px 16px;
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.22);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.intel-label {
|
.metric-card span {
|
||||||
display: block;
|
display: block;
|
||||||
color: #8ea99a;
|
color: #9db0a6;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.13em;
|
|
||||||
font-size: 0.68rem;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.intel-card strong {
|
.metric-card strong {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 4px;
|
margin-top: 8px;
|
||||||
color: #f1fff5;
|
color: #f1fff5;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.intel-card strong.online {
|
.metric-card strong.online {
|
||||||
color: #9fffc3;
|
color: #9fffc3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.jarri-box {
|
.history-panel {
|
||||||
border-radius: 18px;
|
display: grid;
|
||||||
margin-bottom: 20px;
|
|
||||||
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.24);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.section-toolbar {
|
||||||
border-radius: 10px;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-toolbar h2 {
|
||||||
|
color: #f1fff5;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-toolbar p,
|
||||||
|
.empty-state {
|
||||||
|
color: #9db0a6;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-panel {
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #bcd1c4;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-panel i {
|
||||||
|
color: #9fffc3;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-panel p {
|
||||||
|
max-width: 34ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.truth-note,
|
||||||
|
.archive-row,
|
||||||
|
.inspector-list,
|
||||||
|
.rejected-list {
|
||||||
|
color: #bcd1c4;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row {
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid rgba(126, 255, 180, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.jarri-hero,
|
.workspace-titlebar,
|
||||||
.jarri-body {
|
.workspace-controls {
|
||||||
padding: 18px;
|
position: static;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.jarri-hero {
|
.panel-picker {
|
||||||
display: block;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-right {
|
.panel-picker .select,
|
||||||
margin-top: 14px;
|
.panel-picker select,
|
||||||
|
.panel-picker .button,
|
||||||
|
.reset-button {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.intelligence-grid {
|
.workspace-canvas {
|
||||||
grid-template-columns: 1fr 1fr;
|
width: 1848px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Component} from '@angular/core';
|
import {Component, OnDestroy} from '@angular/core';
|
||||||
import {FormsModule} from "@angular/forms";
|
import {FormsModule} from "@angular/forms";
|
||||||
import {CommonModule, NgFor} from "@angular/common";
|
import {CommonModule, NgFor} from "@angular/common";
|
||||||
import {PlaylistService, PlaylistStatusEnum} from "./services/playlist.service";
|
import {PlaylistService, PlaylistStatusEnum} from "./services/playlist.service";
|
||||||
@@ -6,6 +6,198 @@ import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.compo
|
|||||||
import {VersionService} from "./services/version.service";
|
import {VersionService} from "./services/version.service";
|
||||||
import {map} from "rxjs";
|
import {map} from "rxjs";
|
||||||
import {HttpClient} from "@angular/common/http";
|
import {HttpClient} from "@angular/common/http";
|
||||||
|
import {ArchiveService} from "./services/archive.service";
|
||||||
|
import {ArchiveFile, ArchiveListing} from "./models/archive";
|
||||||
|
import {Track, TrackStatusEnum} from "./models/track";
|
||||||
|
import {TrackService} from "./services/track.service";
|
||||||
|
|
||||||
|
export type SpootyPanelType =
|
||||||
|
'source-intake'
|
||||||
|
| 'queue-observatory'
|
||||||
|
| 'playlist-history'
|
||||||
|
| 'single-songs'
|
||||||
|
| 'archive-browser'
|
||||||
|
| 'candidate-inspector';
|
||||||
|
|
||||||
|
export interface SpootyPanelInstance {
|
||||||
|
id: string;
|
||||||
|
type: SpootyPanelType;
|
||||||
|
title: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpootyWorkspaceState {
|
||||||
|
panels: SpootyPanelInstance[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpootyWorkspaceTab = 'intake' | 'archive' | 'diagnostics';
|
||||||
|
export type ArchiveSortMode = 'newest' | 'name' | 'size';
|
||||||
|
|
||||||
|
export interface SpootyWorkspaceTabsState {
|
||||||
|
activeTab: SpootyWorkspaceTab;
|
||||||
|
tabs: Record<SpootyWorkspaceTab, SpootyWorkspaceState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORKSPACE_STORAGE_KEY = 'jarri_spooty_workspace_state_v1';
|
||||||
|
const WORKSPACE_TABS_STORAGE_KEY = 'jarri_spooty_workspace_tabs_v1';
|
||||||
|
const ARCHIVE_DESTINATION_KEY = 'jarri_spooty_archive_destination_v1';
|
||||||
|
const GRID_SIZE = 12;
|
||||||
|
const MIN_PANEL_WIDTH = 260;
|
||||||
|
const MIN_PANEL_HEIGHT = 160;
|
||||||
|
|
||||||
|
const PANEL_TITLES: Record<SpootyPanelType, string> = {
|
||||||
|
'source-intake': 'Source Intake',
|
||||||
|
'queue-observatory': 'Queue Observatory',
|
||||||
|
'playlist-history': 'Playlist History',
|
||||||
|
'single-songs': 'Single Songs',
|
||||||
|
'archive-browser': 'Archive Browser',
|
||||||
|
'candidate-inspector': 'Candidate Inspector',
|
||||||
|
};
|
||||||
|
|
||||||
|
const WORKSPACE_TAB_LABELS: Record<SpootyWorkspaceTab, string> = {
|
||||||
|
intake: 'Intake',
|
||||||
|
archive: 'Archive',
|
||||||
|
diagnostics: 'Diagnostics',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_TAB_PANELS: Record<SpootyWorkspaceTab, SpootyPanelInstance[]> = {
|
||||||
|
intake: [
|
||||||
|
makePanel('source-intake', 24, 24, 520, 336, 'intake-source-intake'),
|
||||||
|
makePanel('queue-observatory', 568, 24, 520, 260, 'intake-queue-observatory'),
|
||||||
|
makePanel('playlist-history', 24, 392, 760, 448, 'intake-playlist-history'),
|
||||||
|
makePanel('single-songs', 820, 392, 520, 448, 'intake-single-songs'),
|
||||||
|
],
|
||||||
|
archive: [
|
||||||
|
makePanel('archive-browser', 24, 24, 900, 720, 'archive-archive-browser'),
|
||||||
|
makePanel('source-intake', 960, 24, 420, 336, 'archive-source-intake'),
|
||||||
|
makePanel('queue-observatory', 960, 396, 420, 260, 'archive-queue-observatory'),
|
||||||
|
],
|
||||||
|
diagnostics: [
|
||||||
|
makePanel('candidate-inspector', 24, 24, 620, 720, 'diagnostics-candidate-inspector'),
|
||||||
|
makePanel('playlist-history', 680, 24, 620, 520, 'diagnostics-playlist-history'),
|
||||||
|
makePanel('queue-observatory', 1320, 24, 420, 260, 'diagnostics-queue-observatory'),
|
||||||
|
makePanel('archive-browser', 1320, 320, 420, 420, 'diagnostics-archive-browser'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function makePanel(
|
||||||
|
type: SpootyPanelType,
|
||||||
|
x = 48,
|
||||||
|
y = 48,
|
||||||
|
w = 520,
|
||||||
|
h = 320,
|
||||||
|
id = `${type}-${Date.now()}`,
|
||||||
|
): SpootyPanelInstance {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
title: PANEL_TITLES[type],
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultWorkspaceState(): SpootyWorkspaceState {
|
||||||
|
return defaultWorkspaceStateForTab('intake');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultWorkspaceStateForTab(tab: SpootyWorkspaceTab): SpootyWorkspaceState {
|
||||||
|
return {
|
||||||
|
panels: DEFAULT_TAB_PANELS[tab].map(panel => ({...panel})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultWorkspaceTabsState(): SpootyWorkspaceTabsState {
|
||||||
|
return {
|
||||||
|
activeTab: 'intake',
|
||||||
|
tabs: {
|
||||||
|
intake: defaultWorkspaceStateForTab('intake'),
|
||||||
|
archive: defaultWorkspaceStateForTab('archive'),
|
||||||
|
diagnostics: defaultWorkspaceStateForTab('diagnostics'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadWorkspaceState(): SpootyWorkspaceState {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(WORKSPACE_STORAGE_KEY);
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return defaultWorkspaceState();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as SpootyWorkspaceState;
|
||||||
|
const panels = parsed.panels
|
||||||
|
?.filter(panel => panel?.id && panel?.type && PANEL_TITLES[panel.type])
|
||||||
|
.map(panel => ({
|
||||||
|
...panel,
|
||||||
|
title: PANEL_TITLES[panel.type],
|
||||||
|
x: snap(panel.x),
|
||||||
|
y: snap(panel.y),
|
||||||
|
w: Math.max(MIN_PANEL_WIDTH, snap(panel.w)),
|
||||||
|
h: Math.max(MIN_PANEL_HEIGHT, snap(panel.h)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return panels?.length ? {panels} : defaultWorkspaceState();
|
||||||
|
} catch {
|
||||||
|
return defaultWorkspaceState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadWorkspaceTabsState(): SpootyWorkspaceTabsState {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(WORKSPACE_TABS_STORAGE_KEY);
|
||||||
|
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as SpootyWorkspaceTabsState;
|
||||||
|
return {
|
||||||
|
activeTab: parsed.activeTab && WORKSPACE_TAB_LABELS[parsed.activeTab] ? parsed.activeTab : 'intake',
|
||||||
|
tabs: {
|
||||||
|
intake: sanitizeWorkspaceState(parsed.tabs?.intake, defaultWorkspaceStateForTab('intake')),
|
||||||
|
archive: sanitizeWorkspaceState(parsed.tabs?.archive, defaultWorkspaceStateForTab('archive')),
|
||||||
|
diagnostics: sanitizeWorkspaceState(parsed.tabs?.diagnostics, defaultWorkspaceStateForTab('diagnostics')),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...defaultWorkspaceTabsState(),
|
||||||
|
tabs: {
|
||||||
|
...defaultWorkspaceTabsState().tabs,
|
||||||
|
intake: loadWorkspaceState(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return defaultWorkspaceTabsState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeWorkspaceState(
|
||||||
|
state: SpootyWorkspaceState | undefined,
|
||||||
|
fallback: SpootyWorkspaceState,
|
||||||
|
): SpootyWorkspaceState {
|
||||||
|
const panels = state?.panels
|
||||||
|
?.filter(panel => panel?.id && panel?.type && PANEL_TITLES[panel.type])
|
||||||
|
.map(panel => ({
|
||||||
|
...panel,
|
||||||
|
title: PANEL_TITLES[panel.type],
|
||||||
|
x: snap(panel.x),
|
||||||
|
y: snap(panel.y),
|
||||||
|
w: Math.max(MIN_PANEL_WIDTH, snap(panel.w)),
|
||||||
|
h: Math.max(MIN_PANEL_HEIGHT, snap(panel.h)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return panels?.length ? {panels} : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snap(value: number): number {
|
||||||
|
return Math.round(value / GRID_SIZE) * GRID_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -14,10 +206,32 @@ import {HttpClient} from "@angular/common/http";
|
|||||||
styleUrl: './app.component.scss',
|
styleUrl: './app.component.scss',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
})
|
})
|
||||||
export class AppComponent {
|
export class AppComponent implements OnDestroy {
|
||||||
|
|
||||||
url = ''
|
url = ''
|
||||||
private readonly spotifyUrlPattern = /^https:\/\/open\.spotify\.com\/(track|playlist|album|artist)\/[a-zA-Z0-9]+/;
|
private readonly spotifyUrlPattern = /^https:\/\/open\.spotify\.com\/(track|playlist|album|artist)\/[a-zA-Z0-9]+/;
|
||||||
|
private interaction?: {
|
||||||
|
mode: 'drag' | 'resize';
|
||||||
|
panelId: string;
|
||||||
|
startPointerX: number;
|
||||||
|
startPointerY: number;
|
||||||
|
startPanelX: number;
|
||||||
|
startPanelY: number;
|
||||||
|
startPanelW: number;
|
||||||
|
startPanelH: number;
|
||||||
|
};
|
||||||
|
readonly panelTypes: SpootyPanelType[] = [
|
||||||
|
'source-intake',
|
||||||
|
'queue-observatory',
|
||||||
|
'playlist-history',
|
||||||
|
'single-songs',
|
||||||
|
'archive-browser',
|
||||||
|
'candidate-inspector',
|
||||||
|
];
|
||||||
|
readonly workspaceTabs: SpootyWorkspaceTab[] = ['intake', 'archive', 'diagnostics'];
|
||||||
|
readonly archiveSortModes: ArchiveSortMode[] = ['newest', 'name', 'size'];
|
||||||
|
selectedPanelType: SpootyPanelType = 'source-intake';
|
||||||
|
workspaceTabsState: SpootyWorkspaceTabsState = loadWorkspaceTabsState();
|
||||||
|
|
||||||
get isValidSpotifyUrl(): boolean {
|
get isValidSpotifyUrl(): boolean {
|
||||||
return this.spotifyUrlPattern.test(this.url);
|
return this.spotifyUrlPattern.test(this.url);
|
||||||
@@ -25,17 +239,41 @@ export class AppComponent {
|
|||||||
createLoading$ = this.playlistService.createLoading$;
|
createLoading$ = this.playlistService.createLoading$;
|
||||||
playlists$ = this.playlistService.all$.pipe(map(items => items.filter(item => !item.isTrack)));
|
playlists$ = this.playlistService.all$.pipe(map(items => items.filter(item => !item.isTrack)));
|
||||||
songs$ = this.playlistService.all$.pipe(map(items => items.filter(item => item.isTrack)));
|
songs$ = this.playlistService.all$.pipe(map(items => items.filter(item => item.isTrack)));
|
||||||
|
allTracks$ = this.trackService.all$;
|
||||||
|
selectedTrack$ = this.trackService.selectedTrack$;
|
||||||
version = this.versionService.getVersion();
|
version = this.versionService.getVersion();
|
||||||
spotifyConnected = false;
|
spotifyConnected = false;
|
||||||
|
archiveDestination = localStorage.getItem(ARCHIVE_DESTINATION_KEY) || '';
|
||||||
|
archiveListing?: ArchiveListing;
|
||||||
|
archiveLoading = false;
|
||||||
|
archiveError = '';
|
||||||
|
archiveSearch = '';
|
||||||
|
archiveSort: ArchiveSortMode = 'newest';
|
||||||
|
trackStatuses = TrackStatusEnum;
|
||||||
|
|
||||||
|
get activeWorkspaceTab(): SpootyWorkspaceTab {
|
||||||
|
return this.workspaceTabsState.activeTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
get workspace(): SpootyWorkspaceState {
|
||||||
|
return this.workspaceTabsState.tabs[this.activeWorkspaceTab];
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly playlistService: PlaylistService,
|
private readonly playlistService: PlaylistService,
|
||||||
private readonly versionService: VersionService,
|
private readonly versionService: VersionService,
|
||||||
private readonly http: HttpClient,
|
private readonly http: HttpClient,
|
||||||
|
private readonly archiveService: ArchiveService,
|
||||||
|
private readonly trackService: TrackService,
|
||||||
) {
|
) {
|
||||||
this.bootstrapAuthTokenFromUrl();
|
this.bootstrapAuthTokenFromUrl();
|
||||||
this.checkSpotifyStatus();
|
this.checkSpotifyStatus();
|
||||||
this.fetchPlaylists();
|
this.fetchPlaylists();
|
||||||
|
this.refreshArchive();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.stopWorkspaceInteraction();
|
||||||
}
|
}
|
||||||
|
|
||||||
private bootstrapAuthTokenFromUrl(): void {
|
private bootstrapAuthTokenFromUrl(): void {
|
||||||
@@ -66,6 +304,7 @@ export class AppComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
download(): void {
|
download(): void {
|
||||||
|
this.saveArchiveDestination();
|
||||||
this.url && this.playlistService.create(this.url);
|
this.url && this.playlistService.create(this.url);
|
||||||
this.url = '';
|
this.url = '';
|
||||||
}
|
}
|
||||||
@@ -77,4 +316,317 @@ export class AppComponent {
|
|||||||
deleteFailed(): void {
|
deleteFailed(): void {
|
||||||
this.playlistService.deleteAllByStatus(PlaylistStatusEnum.Error);
|
this.playlistService.deleteAllByStatus(PlaylistStatusEnum.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saveArchiveDestination(): void {
|
||||||
|
localStorage.setItem(ARCHIVE_DESTINATION_KEY, this.archiveDestination);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshArchive(): void {
|
||||||
|
this.archiveLoading = true;
|
||||||
|
this.archiveError = '';
|
||||||
|
this.archiveService.list().subscribe({
|
||||||
|
next: (listing) => {
|
||||||
|
this.archiveListing = listing;
|
||||||
|
this.archiveDestination ||= listing.root;
|
||||||
|
this.archiveLoading = false;
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.archiveError = 'Archive listing is unavailable.';
|
||||||
|
this.archiveLoading = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
formatBytes(sizeBytes: number): string {
|
||||||
|
if (sizeBytes < 1024) {
|
||||||
|
return `${sizeBytes} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sizeBytes < 1024 * 1024) {
|
||||||
|
return `${(sizeBytes / 1024).toFixed(1)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${(sizeBytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatModifiedAt(modifiedAt: number): string {
|
||||||
|
return new Date(modifiedAt).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
trackStatusLabel(status?: TrackStatusEnum): string {
|
||||||
|
switch (status) {
|
||||||
|
case TrackStatusEnum.New:
|
||||||
|
return 'New';
|
||||||
|
case TrackStatusEnum.Searching:
|
||||||
|
return 'Searching';
|
||||||
|
case TrackStatusEnum.Queued:
|
||||||
|
return 'Queued';
|
||||||
|
case TrackStatusEnum.Downloading:
|
||||||
|
return 'Downloading';
|
||||||
|
case TrackStatusEnum.Completed:
|
||||||
|
return 'Completed';
|
||||||
|
case TrackStatusEnum.Error:
|
||||||
|
return 'Error';
|
||||||
|
default:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectedUrls(track: Track): string[] {
|
||||||
|
if (Array.isArray(track.rejectedYoutubeUrls)) {
|
||||||
|
return track.rejectedYoutubeUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!track.rejectedYoutubeUrls) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(track.rejectedYoutubeUrls);
|
||||||
|
return Array.isArray(parsed) ? parsed.filter(item => typeof item === 'string') : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectTrack(track: Track): void {
|
||||||
|
this.trackService.select(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelectedTrack(): void {
|
||||||
|
this.trackService.clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
getWorkspaceTabLabel(tab: SpootyWorkspaceTab): string {
|
||||||
|
return WORKSPACE_TAB_LABELS[tab];
|
||||||
|
}
|
||||||
|
|
||||||
|
switchWorkspaceTab(tab: SpootyWorkspaceTab): void {
|
||||||
|
this.workspaceTabsState = {
|
||||||
|
...this.workspaceTabsState,
|
||||||
|
activeTab: tab,
|
||||||
|
};
|
||||||
|
this.saveWorkspaceState();
|
||||||
|
}
|
||||||
|
|
||||||
|
resetLayout(): void {
|
||||||
|
this.setActiveWorkspace(defaultWorkspaceStateForTab(this.activeWorkspaceTab));
|
||||||
|
}
|
||||||
|
|
||||||
|
resetAllLayouts(): void {
|
||||||
|
this.workspaceTabsState = defaultWorkspaceTabsState();
|
||||||
|
this.saveWorkspaceState();
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredArchiveFiles(): ArchiveFile[] {
|
||||||
|
const files = this.archiveListing?.files || [];
|
||||||
|
const query = this.archiveSearch.trim().toLowerCase();
|
||||||
|
const filtered = query
|
||||||
|
? files.filter(file => `${file.name} ${file.path}`.toLowerCase().includes(query))
|
||||||
|
: [...files];
|
||||||
|
|
||||||
|
return filtered.sort((a, b) => {
|
||||||
|
if (this.archiveSort === 'name') {
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.archiveSort === 'size') {
|
||||||
|
return b.sizeBytes - a.sizeBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.modifiedAt - a.modifiedAt;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveGroups(): { folder: string; files: ArchiveFile[] }[] {
|
||||||
|
const groups = new Map<string, ArchiveFile[]>();
|
||||||
|
|
||||||
|
this.filteredArchiveFiles().forEach(file => {
|
||||||
|
const folder = this.topLevelFolder(file);
|
||||||
|
groups.set(folder, [...(groups.get(folder) || []), file]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...groups.entries()].map(([folder, files]) => ({folder, files}));
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveFileCount(): number {
|
||||||
|
return this.filteredArchiveFiles().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveTotalBytes(): number {
|
||||||
|
return this.filteredArchiveFiles().reduce((total, file) => total + file.sizeBytes, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
topLevelFolder(file: ArchiveFile): string {
|
||||||
|
return file.path.includes('/') ? file.path.split('/')[0] : 'Root';
|
||||||
|
}
|
||||||
|
|
||||||
|
folderPath(file: ArchiveFile): string {
|
||||||
|
const index = file.path.lastIndexOf('/');
|
||||||
|
return index === -1 ? 'Root' : file.path.slice(0, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFolder(file: ArchiveFile): boolean {
|
||||||
|
return file.path.includes('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
getPanelTitle(type: SpootyPanelType): string {
|
||||||
|
return PANEL_TITLES[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
addPanel(type: SpootyPanelType = this.selectedPanelType): void {
|
||||||
|
const offset = this.workspace.panels.length * GRID_SIZE;
|
||||||
|
this.setActiveWorkspace({
|
||||||
|
panels: [
|
||||||
|
...this.workspace.panels,
|
||||||
|
makePanel(type, 48 + offset, 72 + offset),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closePanel(panelId: string): void {
|
||||||
|
if (this.workspace.panels.length <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setActiveWorkspace({
|
||||||
|
panels: this.workspace.panels.filter(panel => panel.id !== panelId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
movePanel(panelId: string, x: number, y: number): void {
|
||||||
|
this.updatePanel(panelId, {
|
||||||
|
x: Math.max(0, snap(x)),
|
||||||
|
y: Math.max(0, snap(y)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resizePanel(panelId: string, w: number, h: number): void {
|
||||||
|
this.updatePanel(panelId, {
|
||||||
|
w: Math.max(MIN_PANEL_WIDTH, snap(w)),
|
||||||
|
h: Math.max(MIN_PANEL_HEIGHT, snap(h)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beginDrag(event: PointerEvent, panel: SpootyPanelInstance): void {
|
||||||
|
event.preventDefault();
|
||||||
|
this.bringPanelToFront(panel.id);
|
||||||
|
this.interaction = {
|
||||||
|
mode: 'drag',
|
||||||
|
panelId: panel.id,
|
||||||
|
startPointerX: event.clientX,
|
||||||
|
startPointerY: event.clientY,
|
||||||
|
startPanelX: panel.x,
|
||||||
|
startPanelY: panel.y,
|
||||||
|
startPanelW: panel.w,
|
||||||
|
startPanelH: panel.h,
|
||||||
|
};
|
||||||
|
this.startWorkspaceInteraction();
|
||||||
|
}
|
||||||
|
|
||||||
|
beginResize(event: PointerEvent, panel: SpootyPanelInstance): void {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.bringPanelToFront(panel.id);
|
||||||
|
this.interaction = {
|
||||||
|
mode: 'resize',
|
||||||
|
panelId: panel.id,
|
||||||
|
startPointerX: event.clientX,
|
||||||
|
startPointerY: event.clientY,
|
||||||
|
startPanelX: panel.x,
|
||||||
|
startPanelY: panel.y,
|
||||||
|
startPanelW: panel.w,
|
||||||
|
startPanelH: panel.h,
|
||||||
|
};
|
||||||
|
this.startWorkspaceInteraction();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPanel(index: number, panel: SpootyPanelInstance): string {
|
||||||
|
return panel.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
panelStyle(panel: SpootyPanelInstance): Record<string, string> {
|
||||||
|
return {
|
||||||
|
transform: `translate(${panel.x}px, ${panel.y}px)`,
|
||||||
|
width: `${panel.w}px`,
|
||||||
|
height: `${panel.h}px`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly handlePointerMove = (event: PointerEvent): void => {
|
||||||
|
if (!this.interaction) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = event.clientX - this.interaction.startPointerX;
|
||||||
|
const dy = event.clientY - this.interaction.startPointerY;
|
||||||
|
|
||||||
|
if (this.interaction.mode === 'drag') {
|
||||||
|
this.movePanel(
|
||||||
|
this.interaction.panelId,
|
||||||
|
this.interaction.startPanelX + dx,
|
||||||
|
this.interaction.startPanelY + dy,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resizePanel(
|
||||||
|
this.interaction.panelId,
|
||||||
|
this.interaction.startPanelW + dx,
|
||||||
|
this.interaction.startPanelH + dy,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly handlePointerUp = (): void => {
|
||||||
|
this.stopWorkspaceInteraction();
|
||||||
|
};
|
||||||
|
|
||||||
|
private startWorkspaceInteraction(): void {
|
||||||
|
document.addEventListener('pointermove', this.handlePointerMove);
|
||||||
|
document.addEventListener('pointerup', this.handlePointerUp, {once: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopWorkspaceInteraction(): void {
|
||||||
|
this.interaction = undefined;
|
||||||
|
document.removeEventListener('pointermove', this.handlePointerMove);
|
||||||
|
document.removeEventListener('pointerup', this.handlePointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bringPanelToFront(panelId: string): void {
|
||||||
|
const panel = this.workspace.panels.find(item => item.id === panelId);
|
||||||
|
|
||||||
|
if (!panel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setActiveWorkspace({
|
||||||
|
panels: [
|
||||||
|
...this.workspace.panels.filter(item => item.id !== panelId),
|
||||||
|
panel,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePanel(panelId: string, changes: Partial<SpootyPanelInstance>): void {
|
||||||
|
this.setActiveWorkspace({
|
||||||
|
panels: this.workspace.panels.map(panel =>
|
||||||
|
panel.id === panelId ? {...panel, ...changes} : panel
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setActiveWorkspace(workspace: SpootyWorkspaceState): void {
|
||||||
|
this.workspaceTabsState = {
|
||||||
|
...this.workspaceTabsState,
|
||||||
|
tabs: {
|
||||||
|
...this.workspaceTabsState.tabs,
|
||||||
|
[this.activeWorkspaceTab]: workspace,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.saveWorkspaceState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveWorkspaceState(): void {
|
||||||
|
localStorage.setItem(WORKSPACE_TABS_STORAGE_KEY, JSON.stringify(this.workspaceTabsState));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
<a class="panel-block is-flex is-justify-content-space-between" *ngFor="let track of tracks$ | async">
|
<a class="panel-block is-flex is-justify-content-space-between" *ngFor="let track of tracks$ | async" (click)="select(track)">
|
||||||
<div>
|
<div>
|
||||||
<span>{{ track.artist }} - {{ track.name }}</span>
|
<span>{{ track.artist }} - {{ track.name }}</span>
|
||||||
<a [href]="track.spotifyUrl"
|
<a [href]="track.spotifyUrl"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
class="has-text-primary margin-left" title="Spotify preview of track that will be downloaded">
|
class="has-text-primary margin-left" title="Spotify preview of track that will be downloaded">
|
||||||
<i class="fa-brands fa-spotify"></i>
|
<i class="fa-brands fa-spotify"></i>
|
||||||
</a>
|
</a>
|
||||||
<a [href]="track.youtubeUrl"
|
<a [href]="track.youtubeUrl"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
class="has-text-danger is-color-black" title="Youtube searched track that will be downloaded">
|
class="has-text-danger is-color-black" title="Youtube searched track that will be downloaded">
|
||||||
<i class="fa-brands fa-youtube"></i>
|
<i class="fa-brands fa-youtube"></i>
|
||||||
</a>
|
</a>
|
||||||
<a *ngIf="track.status === trackStatuses.Completed"
|
<a *ngIf="track.status === trackStatuses.Completed"
|
||||||
href="api/track/download/{{track.id}}"
|
href="api/track/download/{{track.id}}"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
class="has-text-info"
|
class="has-text-info"
|
||||||
title="Download downloaded and locally saved file"
|
title="Download downloaded and locally saved file"
|
||||||
download>
|
download>
|
||||||
@@ -20,8 +23,8 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<i *ngIf="track.status === trackStatuses.Error" class="fa-solid fa-repeat hover-icon" title="Retry to download" (click)="retry(track.id)"></i>
|
<i *ngIf="track.status === trackStatuses.Error" class="fa-solid fa-repeat hover-icon" title="Retry to download" (click)="$event.stopPropagation(); retry(track.id)"></i>
|
||||||
<i class="fa-solid fa-xmark hover-icon" title="Remove track from list" (click)="delete(track.id)"></i>
|
<i class="fa-solid fa-xmark hover-icon" title="Remove track from list" (click)="$event.stopPropagation(); delete(track.id)"></i>
|
||||||
<ng-container [ngSwitch]="track.status">
|
<ng-container [ngSwitch]="track.status">
|
||||||
<span *ngSwitchCase="trackStatuses.New" class="tag is-info">New</span>
|
<span *ngSwitchCase="trackStatuses.New" class="tag is-info">New</span>
|
||||||
<span *ngSwitchCase="trackStatuses.Searching" class="tag is-warning">Searching</span>
|
<span *ngSwitchCase="trackStatuses.Searching" class="tag is-warning">Searching</span>
|
||||||
|
|||||||
@@ -30,4 +30,8 @@ export class TrackListComponent {
|
|||||||
retry(id: number): void {
|
retry(id: number): void {
|
||||||
this.service.retry(id);
|
this.service.retry(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select(track: Track): void {
|
||||||
|
this.service.select(track);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export interface ArchiveFile {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
modifiedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchiveListing {
|
||||||
|
root: string;
|
||||||
|
files: ArchiveFile[];
|
||||||
|
}
|
||||||
@@ -4,10 +4,14 @@ export interface Track {
|
|||||||
name: string;
|
name: string;
|
||||||
spotifyUrl: string;
|
spotifyUrl: string;
|
||||||
youtubeUrl: string;
|
youtubeUrl: string;
|
||||||
|
rejectedYoutubeUrls?: string[] | string;
|
||||||
|
downloadAttemptCount?: number;
|
||||||
status: TrackStatusEnum;
|
status: TrackStatusEnum;
|
||||||
playlistId?: number;
|
playlistId?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
coverUrl?: string;
|
coverUrl?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
createdAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum TrackStatusEnum {
|
export enum TrackStatusEnum {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ArchiveListing } from '../models/archive';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ArchiveService {
|
||||||
|
constructor(private readonly http: HttpClient) {}
|
||||||
|
|
||||||
|
list(): Observable<ArchiveListing> {
|
||||||
|
return this.http.get<ArchiveListing>('/api/archive');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import {createStore} from "@ngneat/elf";
|
import {createStore} from "@ngneat/elf";
|
||||||
import {deleteEntities, selectManyByPredicate, upsertEntities, withEntities} from "@ngneat/elf-entities";
|
import {deleteEntities, selectAllEntities, selectManyByPredicate, upsertEntities, withEntities} from "@ngneat/elf-entities";
|
||||||
import {Socket} from "ngx-socket-io";
|
import {Socket} from "ngx-socket-io";
|
||||||
import {map, Observable, tap} from "rxjs";
|
import {BehaviorSubject, map, Observable, tap} from "rxjs";
|
||||||
import {HttpClient} from "@angular/common/http";
|
import {HttpClient} from "@angular/common/http";
|
||||||
import {Track, TrackStatusEnum} from "../models/track";
|
import {Track, TrackStatusEnum} from "../models/track";
|
||||||
|
|
||||||
@@ -19,11 +19,16 @@ enum WsTrackOperation {
|
|||||||
})
|
})
|
||||||
export class TrackService {
|
export class TrackService {
|
||||||
|
|
||||||
|
private readonly selectedTrackSubject = new BehaviorSubject<Track | undefined>(undefined);
|
||||||
|
selectedTrack$ = this.selectedTrackSubject.asObservable();
|
||||||
|
|
||||||
private store = createStore(
|
private store = createStore(
|
||||||
{ name: STORE_NAME },
|
{ name: STORE_NAME },
|
||||||
withEntities<Track>(),
|
withEntities<Track>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
all$ = this.store.pipe(selectAllEntities());
|
||||||
|
|
||||||
getAllByPlaylist(id: number, status?: TrackStatusEnum): Observable<Track[]> {
|
getAllByPlaylist(id: number, status?: TrackStatusEnum): Observable<Track[]> {
|
||||||
return this.store.pipe(
|
return this.store.pipe(
|
||||||
selectManyByPredicate((track) => track?.playlistId === id),
|
selectManyByPredicate((track) => track?.playlistId === id),
|
||||||
@@ -60,9 +65,30 @@ export class TrackService {
|
|||||||
this.http.post(`${ENDPOINT}/retry/${id}`, {}).subscribe();
|
this.http.post(`${ENDPOINT}/retry/${id}`, {}).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select(track: Track): void {
|
||||||
|
this.selectedTrackSubject.next(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelection(): void {
|
||||||
|
this.selectedTrackSubject.next(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
private initWsConnection(): void {
|
private initWsConnection(): void {
|
||||||
this.socket.on(WsTrackOperation.Update, (track: Track) => this.store.update(upsertEntities(track)));
|
this.socket.on(WsTrackOperation.Update, (track: Track) => {
|
||||||
this.socket.on(WsTrackOperation.Delete, ({id}: {id: number}) => this.store.update(deleteEntities(id)));
|
this.store.update(upsertEntities(track));
|
||||||
|
const selected = this.selectedTrackSubject.value;
|
||||||
|
|
||||||
|
if (selected?.id === track.id) {
|
||||||
|
this.selectedTrackSubject.next({...selected, ...track});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.socket.on(WsTrackOperation.Delete, ({id}: {id: number}) => {
|
||||||
|
this.store.update(deleteEntities(id));
|
||||||
|
|
||||||
|
if (this.selectedTrackSubject.value?.id === id) {
|
||||||
|
this.selectedTrackSubject.next(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
this.socket.on(WsTrackOperation.New, ({track, playlistId}: {track: Track, playlistId: number}) =>
|
this.socket.on(WsTrackOperation.New, ({track, playlistId}: {track: Track, playlistId: number}) =>
|
||||||
this.store.update(upsertEntities([{...track, playlistId}]))
|
this.store.update(upsertEntities([{...track, playlistId}]))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,3 +12,114 @@ body {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workspace-tabs {
|
||||||
|
position: sticky;
|
||||||
|
top: 76px;
|
||||||
|
z-index: 19;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 24px 0;
|
||||||
|
background: rgba(8, 15, 15, 0.94);
|
||||||
|
border-bottom: 1px solid rgba(126, 255, 180, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-tab {
|
||||||
|
min-width: 116px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
border: 1px solid rgba(126, 255, 180, 0.18);
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: #bcd1c4;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-tab.active {
|
||||||
|
background: #14211d;
|
||||||
|
color: #9fffc3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-summary div,
|
||||||
|
.archive-file-card {
|
||||||
|
border: 1px solid rgba(126, 255, 180, 0.14);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-summary div {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-summary span,
|
||||||
|
.archive-file-card span,
|
||||||
|
.archive-file-card small {
|
||||||
|
display: block;
|
||||||
|
color: #9db0a6;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-group h3 {
|
||||||
|
margin: 12px 0 6px;
|
||||||
|
color: #f1fff5;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-file-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 22px minmax(0, 1fr) auto auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-file-card i {
|
||||||
|
color: #9fffc3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspector-list {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspector-list dt,
|
||||||
|
.candidate-options strong {
|
||||||
|
color: #f1fff5;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspector-list dd {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-options {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
color: #bcd1c4;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.workspace-tabs {
|
||||||
|
position: static;
|
||||||
|
padding: 8px 14px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-file-card {
|
||||||
|
grid-template-columns: 22px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user