From 56534d1d33d8381297f7faa38eea2e62a03c1557 Mon Sep 17 00:00:00 2001 From: TorMatzAndren Date: Sat, 23 May 2026 01:01:55 +0200 Subject: [PATCH] Release Jarri Spooty Workspace v3.0.0 --- CHANGELOG.md | 43 ++ README.md | 46 ++ package-lock.json | 8 +- package.json | 2 +- src/backend/package.json | 2 +- src/backend/src/app.module.ts | 2 + src/backend/src/archive/archive.controller.ts | 12 + src/backend/src/archive/archive.module.ts | 11 + src/backend/src/archive/archive.service.ts | 62 ++ src/backend/src/track/track.service.ts | 7 +- src/frontend/package.json | 2 +- src/frontend/src/app/app.component.html | 390 +++++++++--- src/frontend/src/app/app.component.scss | 325 ++++++++-- src/frontend/src/app/app.component.ts | 556 +++++++++++++++++- .../track-list/track-list.component.html | 9 +- .../track-list/track-list.component.ts | 4 + src/frontend/src/app/models/archive.ts | 11 + src/frontend/src/app/models/track.ts | 4 + .../src/app/services/archive.service.ts | 15 + .../src/app/services/track.service.ts | 34 +- src/frontend/src/styles.scss | 111 ++++ 21 files changed, 1493 insertions(+), 163 deletions(-) create mode 100644 src/backend/src/archive/archive.controller.ts create mode 100644 src/backend/src/archive/archive.module.ts create mode 100644 src/backend/src/archive/archive.service.ts create mode 100644 src/frontend/src/app/models/archive.ts create mode 100644 src/frontend/src/app/services/archive.service.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3868bdd..5491bd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,49 @@ This project follows a practical chronological changelog rather than autogenerat ## 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 - Added Spotify OAuth login flow diff --git a/README.md b/README.md index 9042d83..70a1744 100644 --- a/README.md +++ b/README.md @@ -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. +Current version: 3.0.0 + This hardened branch focuses on: +- ChronoGit-style Workspace UI panels - Large playlist support (>100 tracks) - Spotify OAuth login flow - Better YouTube pacing and throttling resistance @@ -27,6 +30,10 @@ This hardened branch focuses on: - Improved operational observability - Reduced unused dependency surface - 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 - Hardened cover-art validation and embedding - 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. --- diff --git a/package-lock.json b/package-lock.json index 23c8109..a7cfa9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "spooty", - "version": "2.4.2", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "spooty", - "version": "2.4.2", + "version": "3.0.0", "workspaces": [ "src/backend", "src/frontend" @@ -25508,7 +25508,7 @@ "license": "MIT" }, "src/backend": { - "version": "2.4.2", + "version": "3.0.0", "license": "UNLICENSED", "dependencies": { "@nestjs/bullmq": "^10.2.3", @@ -25571,7 +25571,7 @@ "license": "MIT" }, "src/frontend": { - "version": "2.4.2", + "version": "3.0.0", "dependencies": { "@angular/animations": "^19.2.22", "@angular/common": "^19.2.22", diff --git a/package.json b/package.json index 64e518d..d0ff46d 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "2.4.2", + "version": "3.0.0", "name": "spooty", "workspaces": [ "src/backend", diff --git a/src/backend/package.json b/src/backend/package.json index f96b77e..7872c98 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "2.4.2", + "version": "3.0.0", "description": "", "author": "", "private": true, diff --git a/src/backend/src/app.module.ts b/src/backend/src/app.module.ts index 12caa6e..073c5d0 100644 --- a/src/backend/src/app.module.ts +++ b/src/backend/src/app.module.ts @@ -14,6 +14,7 @@ import { BullModule } from '@nestjs/bullmq'; import { APP_GUARD } from '@nestjs/core'; import { AuthGuard } from './shared/auth.guard'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; +import { ArchiveModule } from './archive/archive.module'; @Module({ imports: [ @@ -79,6 +80,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; }), TrackModule, PlaylistModule, + ArchiveModule, ], controllers: [AppController], providers: [ diff --git a/src/backend/src/archive/archive.controller.ts b/src/backend/src/archive/archive.controller.ts new file mode 100644 index 0000000..f732f65 --- /dev/null +++ b/src/backend/src/archive/archive.controller.ts @@ -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 { + return this.archiveService.list(); + } +} diff --git a/src/backend/src/archive/archive.module.ts b/src/backend/src/archive/archive.module.ts new file mode 100644 index 0000000..2f5fda4 --- /dev/null +++ b/src/backend/src/archive/archive.module.ts @@ -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 {} diff --git a/src/backend/src/archive/archive.service.ts b/src/backend/src/archive/archive.service.ts new file mode 100644 index 0000000..2332d1a --- /dev/null +++ b/src/backend/src/archive/archive.service.ts @@ -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 { + 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 { + 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; + } +} diff --git a/src/backend/src/track/track.service.ts b/src/backend/src/track/track.service.ts index 809153f..2ddd745 100644 --- a/src/backend/src/track/track.service.ts +++ b/src/backend/src/track/track.service.ts @@ -20,6 +20,10 @@ enum WsTrackOperation { Delete = 'trackDelete', } +type ClientTrack = Omit, 'rejectedYoutubeUrls'> & { + rejectedYoutubeUrls?: string[]; +}; + @WebSocketGateway() @Injectable() export class TrackService { @@ -56,13 +60,14 @@ export class TrackService { this.io.emit(WsTrackOperation.Delete, { id }); } - private toClientTrack(track: TrackEntity): Partial { + private toClientTrack(track: TrackEntity): ClientTrack { return { id: track.id, artist: track.artist, name: track.name, spotifyUrl: track.spotifyUrl, youtubeUrl: track.youtubeUrl, + rejectedYoutubeUrls: this.parseRejectedYoutubeUrls(track), downloadAttemptCount: track.downloadAttemptCount, status: track.status, error: track.error, diff --git a/src/frontend/package.json b/src/frontend/package.json index 2599fe2..a4b6215 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "2.4.2", + "version": "3.0.0", "scripts": { "ng": "ng", "start": "ng serve --proxy-config proxy.conf.json", diff --git a/src/frontend/src/app/app.component.html b/src/frontend/src/app/app.component.html index 2e77ef7..d72a041 100644 --- a/src/frontend/src/app/app.component.html +++ b/src/frontend/src/app/app.component.html @@ -1,94 +1,322 @@ -
-
-

Jarri subsystem · deterministic media ingestion

-

- - Jarri Spooty - v{{version}} -

-

Spotify metadata → YouTube candidate scoring → Local archive

-
+
+
+
+
+ +

Jarri Spooty

+ v{{version}} +
+

Spotify metadata → YouTube candidate scoring → Local archive

+
-
+
+ + + {{ spotifyConnected ? 'Spotify connected' : 'Spotify disconnected' }} + +
+
+ +
+ -
-
-
- Spotify - {{ spotifyConnected ? 'Connected' : 'Disconnected' }} +
+
+
+ +
+
-
- Matching - Duration-aware -
-
- Queue - Paced -
-
- Mode - Archive run -
-
-
-

Acquire music

-
- - + +
+
+ +
+
+
+
+ + {{ panel.title }} +
+ + +
+ +
+
+ + +
+ + -
-

- Please enter a valid Spotify URL. -

-
+ > + + Queue + + -
-
-
-

Playlist History

-

One collapsed row per playlist run.

+ + + +
+

Runtime download root: {{ archiveListing?.root || archiveDestination || 'loading...' }}

+

Per-run destination routing: pending backend support

+
+ +

+ Please enter a valid Spotify URL. +

+
+ +
+ + +
+ Spotify + {{ spotifyConnected ? 'Connected' : 'Disconnected' }} +
+
+ Playlists + {{ playlists.length }} +
+
+ Single Songs + {{ songs.length }} +
+
+ Mode + Archive run +
+
+
+
+ +
+
+
+

Playlist History

+

One collapsed row per playlist run.

+
+
+ + +
+
+ + + +

No playlists

+
+
+ +
+
+
+

Single Songs

+

Direct Spotify track queue entries.

+
+
+ + + +

No songs

+
+
+ +
+
+
+

Archive Browser

+

Root: {{ archiveListing?.root || 'loading...' }}

+
+ +
+ +

{{ archiveError }}

+ +
+
+ {{ archiveFileCount() }} + files +
+
+ {{ formatBytes(archiveTotalBytes()) }} + total +
+
+ Read-only + inspection +
+
+ +
+ +
+ +
+
+ +

No downloaded files found.

+ +
+

+ + {{ group.folder }} +

+ +
+ +
+ {{ file.name }} + {{ folderPath(file) }} +
+ {{ formatBytes(file.sizeBytes) }} + {{ formatModifiedAt(file.modifiedAt) }} +
+
+
+
+ +
+ +
+
+

{{ selectedTrack.artist }} - {{ selectedTrack.name }}

+

Status: {{ trackStatusLabel(selectedTrack.status) }}

+
+ +
+ +
+
Spotify URL
+
{{ selectedTrack.spotifyUrl || 'none' }}
+
Status
+
{{ trackStatusLabel(selectedTrack.status) }}
+
Download attempts
+
{{ selectedTrack.downloadAttemptCount || 0 }}
+
Error
+
{{ selectedTrack.error || 'none' }}
+
+ +
+ Other options +

+ Selected candidate: + {{ selectedTrack.youtubeUrl }} +

+

Rejected candidates: {{ rejectedUrls(selectedTrack).length }}

+ {{ url }} +

+ Candidate alternatives are not persisted yet. Future scoring history will appear here. +

+
+
+ + +
+ +

Select a track row inside an expanded playlist to inspect Spotify, YouTube, retry, and error details.

+
+ + + +
+
-
- - -
- - - -

No playlists

-
- - -
-

Single Songs

- - -

No songs

-
-
- + + + + diff --git a/src/frontend/src/app/app.component.scss b/src/frontend/src/app/app.component.scss index 0ab9ed7..96e55c0 100644 --- a/src/frontend/src/app/app.component.scss +++ b/src/frontend/src/app/app.component.scss @@ -1,129 +1,324 @@ :host { display: block; min-height: 100vh; - background: - radial-gradient(circle at top left, rgba(29, 185, 84, 0.18), transparent 30rem), - linear-gradient(135deg, #06110c 0%, #0a1210 45%, #020403 100%); + background: #0d1515; color: #eafff0; } -.jarri-hero { +.workspace-shell { + min-height: 100vh; + overflow: auto; +} + +.workspace-titlebar { + position: sticky; + top: 0; + z-index: 20; display: flex; justify-content: space-between; gap: 24px; - align-items: center; - padding: 34px 44px; - background: - linear-gradient(90deg, rgba(3, 12, 7, 0.95), rgba(29, 185, 84, 0.92)); - border-bottom: 1px solid rgba(126, 255, 180, 0.25); + min-height: 76px; + padding: 14px 24px; + border-bottom: 1px solid rgba(126, 255, 180, 0.22); + background: rgba(5, 11, 10, 0.96); + box-shadow: 0 14px 34px rgba(0, 0, 0, 0.26); } -.eyebrow { - margin: 0 0 6px; - color: #9fffc3; - text-transform: uppercase; - letter-spacing: 0.18em; - font-size: 0.72rem; - font-weight: 800; +.brand-line { + display: flex; + align-items: center; + gap: 10px; +} + +h1, +h2, +p { + margin: 0; } h1 { - margin: 0; color: #f3fff7; - font-size: clamp(2.2rem, 4vw, 4.6rem); + font-size: 1.35rem; font-weight: 900; - letter-spacing: -0.06em; - text-shadow: 0 2px 24px rgba(0, 0, 0, 0.45); } -.hero-subtitle { - margin-top: 8px; - color: #d8ffe5; - font-size: 1.05rem; +.brand-stack p { + margin-top: 4px; + color: #bcd1c4; + font-size: 0.86rem; } .version-tag { - background: #07110c; + background: #12221a; color: #9fffc3; - vertical-align: middle; } -.auth-button { - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.28); - background: rgba(255, 255, 255, 0.92); - color: #07110c; +.truth-pill { + display: inline-flex; + align-items: center; + gap: 8px; + 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; } -.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; } -.jarri-body { - padding: 34px 44px; +.panel-close { + 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; - 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-radius: 16px; - padding: 14px 16px; + border-radius: 8px; 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; - color: #8ea99a; + color: #9db0a6; + font-weight: 900; + letter-spacing: 0.1em; text-transform: uppercase; - letter-spacing: 0.13em; - font-size: 0.68rem; - font-weight: 800; } -.intel-card strong { +.metric-card strong { display: block; - margin-top: 4px; + margin-top: 8px; color: #f1fff5; font-size: 1.1rem; } -.intel-card strong.online { +.metric-card strong.online { color: #9fffc3; } -.jarri-box { - border-radius: 18px; - margin-bottom: 20px; - box-shadow: 0 18px 60px rgba(0, 0, 0, 0.24); +.history-panel { + display: grid; } -.button { - border-radius: 10px; +.section-toolbar { + 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) { - .jarri-hero, - .jarri-body { - padding: 18px; + .workspace-titlebar, + .workspace-controls { + position: static; + align-items: flex-start; + flex-direction: column; + padding: 14px; } - .jarri-hero { - display: block; + .panel-picker { + width: 100%; } - .hero-right { - margin-top: 14px; + .panel-picker .select, + .panel-picker select, + .panel-picker .button, + .reset-button { + width: 100%; } - .intelligence-grid { - grid-template-columns: 1fr 1fr; + .workspace-canvas { + width: 1848px; } } diff --git a/src/frontend/src/app/app.component.ts b/src/frontend/src/app/app.component.ts index 95e7b3c..dc45726 100644 --- a/src/frontend/src/app/app.component.ts +++ b/src/frontend/src/app/app.component.ts @@ -1,4 +1,4 @@ -import {Component} from '@angular/core'; +import {Component, OnDestroy} from '@angular/core'; import {FormsModule} from "@angular/forms"; import {CommonModule, NgFor} from "@angular/common"; 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 {map} from "rxjs"; 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; +} + +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 = { + '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 = { + intake: 'Intake', + archive: 'Archive', + diagnostics: 'Diagnostics', +}; + +const DEFAULT_TAB_PANELS: Record = { + 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({ selector: 'app-root', @@ -14,10 +206,32 @@ import {HttpClient} from "@angular/common/http"; styleUrl: './app.component.scss', standalone: true, }) -export class AppComponent { +export class AppComponent implements OnDestroy { url = '' 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 { return this.spotifyUrlPattern.test(this.url); @@ -25,17 +239,41 @@ export class AppComponent { createLoading$ = this.playlistService.createLoading$; playlists$ = 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(); 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( private readonly playlistService: PlaylistService, private readonly versionService: VersionService, private readonly http: HttpClient, + private readonly archiveService: ArchiveService, + private readonly trackService: TrackService, ) { this.bootstrapAuthTokenFromUrl(); this.checkSpotifyStatus(); this.fetchPlaylists(); + this.refreshArchive(); + } + + ngOnDestroy(): void { + this.stopWorkspaceInteraction(); } private bootstrapAuthTokenFromUrl(): void { @@ -66,6 +304,7 @@ export class AppComponent { } download(): void { + this.saveArchiveDestination(); this.url && this.playlistService.create(this.url); this.url = ''; } @@ -77,4 +316,317 @@ export class AppComponent { deleteFailed(): void { 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(); + + 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 { + 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): 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)); + } } diff --git a/src/frontend/src/app/components/track-list/track-list.component.html b/src/frontend/src/app/components/track-list/track-list.component.html index 0615088..0da4df1 100644 --- a/src/frontend/src/app/components/track-list/track-list.component.html +++ b/src/frontend/src/app/components/track-list/track-list.component.html @@ -1,18 +1,21 @@ - +
-   -   +   +   New Searching diff --git a/src/frontend/src/app/components/track-list/track-list.component.ts b/src/frontend/src/app/components/track-list/track-list.component.ts index 6205260..229fa54 100644 --- a/src/frontend/src/app/components/track-list/track-list.component.ts +++ b/src/frontend/src/app/components/track-list/track-list.component.ts @@ -30,4 +30,8 @@ export class TrackListComponent { retry(id: number): void { this.service.retry(id); } + + select(track: Track): void { + this.service.select(track); + } } diff --git a/src/frontend/src/app/models/archive.ts b/src/frontend/src/app/models/archive.ts new file mode 100644 index 0000000..bd6c0ee --- /dev/null +++ b/src/frontend/src/app/models/archive.ts @@ -0,0 +1,11 @@ +export interface ArchiveFile { + name: string; + path: string; + sizeBytes: number; + modifiedAt: number; +} + +export interface ArchiveListing { + root: string; + files: ArchiveFile[]; +} diff --git a/src/frontend/src/app/models/track.ts b/src/frontend/src/app/models/track.ts index 0ae4384..643ff8b 100644 --- a/src/frontend/src/app/models/track.ts +++ b/src/frontend/src/app/models/track.ts @@ -4,10 +4,14 @@ export interface Track { name: string; spotifyUrl: string; youtubeUrl: string; + rejectedYoutubeUrls?: string[] | string; + downloadAttemptCount?: number; status: TrackStatusEnum; playlistId?: number; error?: string; coverUrl?: string; + durationMs?: number; + createdAt?: number; } export enum TrackStatusEnum { diff --git a/src/frontend/src/app/services/archive.service.ts b/src/frontend/src/app/services/archive.service.ts new file mode 100644 index 0000000..4e9cf07 --- /dev/null +++ b/src/frontend/src/app/services/archive.service.ts @@ -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 { + return this.http.get('/api/archive'); + } +} diff --git a/src/frontend/src/app/services/track.service.ts b/src/frontend/src/app/services/track.service.ts index a48d590..9dfe087 100644 --- a/src/frontend/src/app/services/track.service.ts +++ b/src/frontend/src/app/services/track.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@angular/core'; 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 {map, Observable, tap} from "rxjs"; +import {BehaviorSubject, map, Observable, tap} from "rxjs"; import {HttpClient} from "@angular/common/http"; import {Track, TrackStatusEnum} from "../models/track"; @@ -19,11 +19,16 @@ enum WsTrackOperation { }) export class TrackService { + private readonly selectedTrackSubject = new BehaviorSubject(undefined); + selectedTrack$ = this.selectedTrackSubject.asObservable(); + private store = createStore( { name: STORE_NAME }, withEntities(), ); + all$ = this.store.pipe(selectAllEntities()); + getAllByPlaylist(id: number, status?: TrackStatusEnum): Observable { return this.store.pipe( selectManyByPredicate((track) => track?.playlistId === id), @@ -60,9 +65,30 @@ export class TrackService { this.http.post(`${ENDPOINT}/retry/${id}`, {}).subscribe(); } + select(track: Track): void { + this.selectedTrackSubject.next(track); + } + + clearSelection(): void { + this.selectedTrackSubject.next(undefined); + } + private initWsConnection(): void { - this.socket.on(WsTrackOperation.Update, (track: Track) => this.store.update(upsertEntities(track))); - this.socket.on(WsTrackOperation.Delete, ({id}: {id: number}) => this.store.update(deleteEntities(id))); + this.socket.on(WsTrackOperation.Update, (track: Track) => { + 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.store.update(upsertEntities([{...track, playlistId}])) ); diff --git a/src/frontend/src/styles.scss b/src/frontend/src/styles.scss index 1c09384..3c2b9e0 100644 --- a/src/frontend/src/styles.scss +++ b/src/frontend/src/styles.scss @@ -12,3 +12,114 @@ body { padding: 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); + } +}