feat: 全新界面改版,新增缩略图、侧边栏导航与 Toast 反馈
This commit is contained in:
parent
1c9cfd84bf
commit
33424409d5
|
|
@ -0,0 +1,335 @@
|
|||
# MeTube UI Redesign — Implementation Plan
|
||||
|
||||
Date: 2026-08-11
|
||||
Status: Plan (ready to implement)
|
||||
Design source: [`design/metube-redesign.html`](design/metube-redesign.html) (this plan supersedes §6 of [`UI_REDESIGN.md`](UI_REDESIGN.md))
|
||||
|
||||
> **Implementation log (2026-08-11):** Phases 1–5 are implemented and
|
||||
> verified. Deltas from this plan: the completed **grid** uses the accepted
|
||||
> fallback (§Phase 3 item 4) — plain CSS grid with `loading="lazy"` images
|
||||
> rather than chunked row virtualization; the pre-existing broken `test`/
|
||||
> karma setup and the tslint/codelyzer/protractor scaffolding were removed in
|
||||
> Phase 5; the UI was verified headless (desktop + 375px mobile) against a
|
||||
> live backend with seeded queue/completed data, including the `/thumbnail`
|
||||
> endpoint.
|
||||
|
||||
## 0. What the mockup improves over the original proposal
|
||||
|
||||
`metube-redesign.html` implements the spirit of `UI_REDESIGN.md` but resolves
|
||||
several open questions and simplifies the riskiest parts:
|
||||
|
||||
1. **Folder grouping moved from in-list headers to a sidebar nav.** The doc's
|
||||
open question ("per-folder navigation") is answered: folders are a
|
||||
first-class sidebar section with counts, and the list itself is flat. This
|
||||
also removes the doc's biggest technical risk — flattening collapsible
|
||||
group headers into a `cdk-virtual-scroll-viewport` — entirely.
|
||||
2. **A stats strip** (active count, total speed, queue count, library size)
|
||||
replaces the navbar counters.
|
||||
3. **Active downloads become a pinned thumbnail panel** with progress, speed,
|
||||
ETA and cancel, instead of the top tab of a table.
|
||||
4. **Completed items get a media-library grid** with a list/grid toggle,
|
||||
duration badges and hover actions.
|
||||
5. **A floating batch bar** (Start / Move / Delete / clear) replaces the
|
||||
always-visible disabled toolbar buttons.
|
||||
6. **Errors and feedback move to toasts** with optional action buttons.
|
||||
7. **Empty states** for every list, and a single-row command bar (URL input +
|
||||
quality/format chips + folder picker) instead of the full form.
|
||||
8. **Mobile**: the sidebar becomes a drawer and everything reflows to
|
||||
single-column — no horizontal table scroll.
|
||||
|
||||
## 1. Constraints & assumptions
|
||||
|
||||
- **Stay on the current stack**: Angular 19 + Bootstrap 5.3 + ng-bootstrap +
|
||||
ng-select + FontAwesome + PWA. No framework rewrite (same rationale as the
|
||||
design doc).
|
||||
- **Labels are Chinese** per the mockup (`lang="zh-CN"`). The current app is
|
||||
English; the mockup is the source of truth, and this matches the fork's
|
||||
usage. All strings are inline template text so switching language later is a
|
||||
mechanical pass.
|
||||
- **No new features beyond the design** — no playlist monitor, no new download
|
||||
options. The one additive backend change is the thumbnail endpoint (§5).
|
||||
- **Mockup data vs real data deltas** (implement from real data, not the
|
||||
mockup):
|
||||
- `MKV` format chip: the backend's `Formats` list has no `mkv`; chips are
|
||||
derived from the real list (`any`, `mp4`, `m4a`, `mp3`, `opus`, `wav`,
|
||||
`flac`, `thumbnail`).
|
||||
- Queued rows show size in the mockup, but size is unknown until download
|
||||
starts; render `—` until `size` is available.
|
||||
- "Reveal in folder" has no backend endpoint; map it to opening the download
|
||||
URL (skip the icon until a backend reveal exists).
|
||||
- Folder nav counts are computed from real maps, not the mockup's numbers.
|
||||
- **Theme**: keep the existing light/dark/auto Bootstrap mechanism; the
|
||||
mockup's palette (green accent, oklch) is implemented as CSS custom
|
||||
properties with dark-mode overrides (§3).
|
||||
- **Fonts**: the mockup loads JetBrains Mono from Google Fonts. For a
|
||||
self-hosted LAN app, do **not** depend on an external font CDN; use the
|
||||
system mono fallback stack from the mockup and optionally bundle
|
||||
`@fontsource/jetbrains-mono` in Phase 5.
|
||||
|
||||
## 2. Target component structure
|
||||
|
||||
Break the 744-line `app.component.ts` / 595-line template monolith into:
|
||||
|
||||
```
|
||||
AppComponent app shell: sidebar + command bar + stats +
|
||||
content + toasts + slide-over; owns nav state,
|
||||
metrics, versions, theme
|
||||
├── SidebarComponent brand/version, status nav (counts), folder
|
||||
│ nav (counts), footer (advanced, activity,
|
||||
│ theme toggle)
|
||||
├── CommandBarComponent URL input + Add, quality/format chips,
|
||||
│ folder picker (ng-select), advanced link
|
||||
├── StatsStripComponent active count · total speed · queue · storage
|
||||
├── ActiveDownloadsComponent pinned panel: rows with thumb, progress,
|
||||
│ speed/ETA, cancel
|
||||
├── DownloadListComponent reusable queued/completed list: filter, sort,
|
||||
│ ├── DownloadRowComponent queue-style row (checkbox, thumb, meta, size,
|
||||
│ │ hover actions)
|
||||
│ └── DownloadCardComponent completed grid card (checkbox, thumb,
|
||||
│ duration, title, folder, size, actions)
|
||||
├── AdvancedOptionsComponent slide-over: download behavior, batch ops,
|
||||
│ cookies
|
||||
├── ActivityDrawerComponent recent events (Phase 5)
|
||||
└── ToastsComponent + service replaces alert()
|
||||
```
|
||||
|
||||
Shared services:
|
||||
|
||||
- `preferences.service.ts` — thin wrapper over `ngx-cookie-service` for
|
||||
`metube_quality`, `metube_format`, `metube_auto_start`, `metube_sort_order`,
|
||||
`metube_active_tab`, `metube_theme`, plus new `metube_status`,
|
||||
`metube_folder`, `metube_view` keys.
|
||||
- `toast.service.ts` — `show(message, {error?, actionLabel?, onAction?})`,
|
||||
auto-dismiss, rendered by `ToastsComponent`.
|
||||
- `downloads.service.ts` — kept **as-is** except: add optional
|
||||
`duration?: number` / `thumbnail?: string` to the `Download` interface
|
||||
(backend §5) and add `moveById(ids, folder)` convenience that calls the
|
||||
existing `POST /set_folder` with an id list.
|
||||
|
||||
The triplicated table markup (queue/pending/done) collapses into two
|
||||
`DownloadListComponent` instances.
|
||||
|
||||
## 3. Design tokens & styling
|
||||
|
||||
New `ui/src/styles/_tokens.sass` (imported from `styles.sass`), defining the
|
||||
mockup's oklch palette as CSS custom properties, with dark overrides under
|
||||
`[data-bs-theme="dark"]`:
|
||||
|
||||
| Token | Light | Dark |
|
||||
|---|---|---|
|
||||
| `--bg` | `oklch(98% 0.005 250)` | `oklch(16% 0.012 250)` |
|
||||
| `--surface` | `oklch(100% 0 0)` | `oklch(20% 0.014 250)` |
|
||||
| `--fg` | `oklch(22% 0.02 240)` | `oklch(92% 0.01 240)` |
|
||||
| `--muted` | `oklch(50% 0.018 240)` | `oklch(68% 0.012 240)` |
|
||||
| `--border` | `oklch(90% 0.008 240)` | `oklch(30% 0.014 250)` |
|
||||
| `--accent` | `oklch(58% 0.16 145)` | `oklch(66% 0.15 145)` |
|
||||
| `--accent-strong` | `oklch(48% 0.14 145)` | `oklch(74% 0.13 145)` |
|
||||
| `--accent-tint` | `oklch(95% 0.03 145)` | `oklch(26% 0.05 145)` |
|
||||
| `--warn` / `--danger` | `oklch(70% 0.15 75)` / `oklch(55% 0.19 25)` | same, lightened |
|
||||
|
||||
Plus `--font-mono` (JetBrains Mono → system fallback), `--radius: 8px`,
|
||||
`--sidebar-w: 236px`. Global styles: `body` background/color/font, `.num`
|
||||
tabular-nums utility, focus-visible accent outline.
|
||||
|
||||
Keep Bootstrap loaded (the app shell, ng-select, modals still use it), but the
|
||||
redesigned surfaces use the tokens via component Sass. The legacy navbar /
|
||||
`styles.sass` overrides are deleted once the shell lands.
|
||||
|
||||
## 4. Phases
|
||||
|
||||
Each phase ends with `cd ui && npm run build` green and the manual checklist
|
||||
in §7 passing for the touched areas. Phases 1–3 deliver the bulk of the UX
|
||||
gain; Phase 4 is separable if backend changes need to wait.
|
||||
|
||||
### Phase 1 — Foundations & component split (no visual change)
|
||||
|
||||
Goal: establish structure and services before touching the design, so
|
||||
regression risk is isolated.
|
||||
|
||||
1. `npm install @angular/cdk@^19` (needed for virtualization in Phase 3).
|
||||
2. Add `_tokens.sass`; wire into `styles.sass` (no visual change yet).
|
||||
3. Add `preferences.service.ts` (migrate existing cookie reads/writes) and
|
||||
`toast.service.ts` + `ToastsComponent`.
|
||||
4. Extract `CommandBarComponent`, `AdvancedOptionsComponent`,
|
||||
`ActiveDownloadsComponent`, `DownloadListComponent` + row/card components
|
||||
from the monolith, keeping the current tables and behavior identical.
|
||||
5. Register everything in `app.module.ts`; delete now-unused `@ViewChild`
|
||||
native-element manipulation and triplicated markup as extraction proceeds.
|
||||
|
||||
Acceptance: UI looks/behaves exactly as before; `npm run build` passes.
|
||||
|
||||
### Phase 2 — App shell & navigation
|
||||
|
||||
Goal: the mockup's frame — sidebar, stats strip, command bar, slide-over,
|
||||
pinned active panel — over the still-table-based lists.
|
||||
|
||||
1. **Sidebar**: brand + version (from existing `/version` fetch), status nav
|
||||
(正在下载 / 排队中 / 已完成 with live count badges), folder nav (全部 +
|
||||
each distinct `folder` value with counts, 未分类 for `''`), footer with
|
||||
高级选项, 日志, and a theme toggle (preserves existing light/dark/auto).
|
||||
Replace the Bootstrap tabs; persist `metube_status` / `metube_folder`.
|
||||
2. **Stats strip**: active count, total speed (`totalSpeed | speed`), queue
|
||||
count, library size (sum of `size` over `done`, `FileSizePipe`).
|
||||
3. **Command bar**: URL input + 添加到队列, quality chips and format chips
|
||||
derived from the real `Formats`/qualities (render as chips; if more than
|
||||
~4, a "more" popover — keep current `setQualities()` behavior), folder
|
||||
picker (ng-select with tag creation), 高级选项 link.
|
||||
4. **AdvancedOptionsComponent slide-over** with scrim, Escape/click-close:
|
||||
auto-start, strict playlist, playlist limit, name prefix, import/export/
|
||||
copy URLs, cookies (existing `POST /cookie`; show saved cookie domain from
|
||||
configuration if available). Wire batch import modal into the slide-over.
|
||||
5. **ActiveDownloadsComponent** pinned above the list: rows with 16:9
|
||||
thumbnail placeholder, title, progress bar, pct/size/speed/ETA, cancel.
|
||||
Until Phase 4, thumbnails show the placeholder block.
|
||||
6. **Events**: keep the existing inline events box under the new shell (or
|
||||
move to a toasts-driven minimal version); full drawer is Phase 5.
|
||||
|
||||
Acceptance: all add/start/cancel/delete/folder/cookie/import-export flows work
|
||||
from the new shell; tab cookie `metube_active_tab` still respected (maps
|
||||
queue→downloading, pending→queued, done→completed).
|
||||
|
||||
### Phase 3 — List redesign
|
||||
|
||||
Goal: replace tables with the mockup's lists — filter, sort, multi-select,
|
||||
batch bar, grid/list toggle, empty states, virtualization.
|
||||
|
||||
1. **DownloadListComponent** for queued and completed:
|
||||
- Toolbar: filter input (title substring), sort select (date / title /
|
||||
size) + direction toggle (preserve existing asc/desc cookie), view toggle
|
||||
(list/grid; grid shown only for completed), 全选, result count.
|
||||
- Flat items only — folder filtering comes from the sidebar nav, so no
|
||||
group-header flattening is needed.
|
||||
- Selection state: a `Set<string>` of ids owned by the list component,
|
||||
cleared on nav change. Batch actions emit id lists up to `AppComponent`,
|
||||
which calls `startById` / `delById` / `setFolder` directly (removes the
|
||||
current `dl.checked` mutation and `MasterCheckbox` machinery).
|
||||
- Batch bar: floating bottom bar (mockup styling) — queued: 开始 / 移动到…
|
||||
/ 删除 / 取消选择; completed: 移动到… / 删除 / 取消选择 (start N/A).
|
||||
Completed toolbar keeps 清除已完成 / 清除失败 / 重试失败 / 下载所选 as
|
||||
ghost buttons.
|
||||
- Empty states per mockup (正在下载 fixed-on-top hint; 没有匹配的条目 for
|
||||
filter misses; plus a true empty-state for an empty queue/library).
|
||||
- Skeleton rows while `downloads.loading` (first connect replaces
|
||||
"Connecting to server...").
|
||||
2. **DownloadRowComponent** (queued): checkbox, 88px thumb, title, folder +
|
||||
quality·format badges, added time, size (`—` when unknown), hover actions:
|
||||
start (pending only), cancel/delete, external link, folder edit (ng-select
|
||||
with tag creation → `POST /set_folder`).
|
||||
3. **DownloadCardComponent** (completed): hover checkbox, 16:9 thumb with
|
||||
duration badge, 2-line title, folder badge, size, actions: download file,
|
||||
external link, folder edit, delete, retry (error items). Grid uses
|
||||
`repeat(auto-fill, minmax(218px, 1fr))` per mockup.
|
||||
4. **Virtualization** (queued list + completed grid):
|
||||
- Queued: `cdk-virtual-scroll-viewport` with fixed row height over a flat
|
||||
array.
|
||||
- Completed grid: chunk items into fixed-height rows of N columns (N from a
|
||||
`ResizeObserver` on the container) and virtualize rows. Fallback if
|
||||
chunking proves fiddly: non-virtualized grid with `loading="lazy"` images
|
||||
(303 cards is acceptable; re-evaluate if scrolling degrades).
|
||||
- `trackBy` on item id.
|
||||
|
||||
Acceptance: smooth filter/sort/scroll at 230 queued + 303 completed; batch
|
||||
select/start/cancel/move/delete correct; folder nav filters; selection resets
|
||||
on nav change; all previous row actions (folder edit, retry, download file)
|
||||
still work.
|
||||
|
||||
### Phase 4 — Thumbnails (backend additive)
|
||||
|
||||
Goal: thumbnail-first rows/cards without hot-linking remote images.
|
||||
|
||||
Backend (`app/ytdl.py`, `app/main.py` — no queue/precheck/persistence changes):
|
||||
|
||||
1. `DownloadInfo` gains `self.thumbnail` and `self.duration` extracted from the
|
||||
yt-dlp `entry` (`entry.get('thumbnail') or thumbnails[-1]`, `entry.get(
|
||||
'duration')`) — these already persist through the existing shelve files, so
|
||||
completed items survive restarts.
|
||||
2. New route `GET /thumbnail?id=<download_id>`:
|
||||
- Look up the id across queue/pending/done shelves, take the stored
|
||||
`thumbnail` URL (never an arbitrary client-supplied URL).
|
||||
- Fetch with `aiohttp.ClientSession`, cache bytes under
|
||||
`STATE_DIR/thumbnails/<id>.<ext>` with long-lived `Cache-Control`.
|
||||
- 404/placeholder SVG when no thumbnail exists (audio-only downloads).
|
||||
3. Optionally, trim the socket payload: exclude the huge `entry` dict from
|
||||
serialized `DownloadInfo` now that only `thumbnail`/`duration` are needed
|
||||
client-side (keeps the existing `all` event contract otherwise intact).
|
||||
|
||||
Frontend:
|
||||
|
||||
1. `Download` interface gains `duration?: number`, `thumbnail?: string`.
|
||||
2. `thumbnailUrl(id)` helper → `/thumbnail?id=…`; `<img loading="lazy">` in
|
||||
rows, cards and the active panel; placeholder block while missing/loading.
|
||||
3. Duration formatting pipe (`mm:ss` / `h:mm:ss`) for the badge.
|
||||
|
||||
Acceptance: thumbnails render for queued/active/completed after a cold reload;
|
||||
no third-party image requests leave the host; audio items show placeholder.
|
||||
|
||||
### Phase 5 — Polish & cleanup
|
||||
|
||||
1. Dark theme pass over every surface (verify both palettes at 375/768/1280px).
|
||||
2. Mobile: sidebar drawer with hamburger (mockup behavior), command bar
|
||||
wraps, batch bar safe-area spacing, slide-over becomes a bottom sheet at
|
||||
narrow widths.
|
||||
3. Activity drawer (`ActivityDrawerComponent`) for recent events with clear
|
||||
button, fed by existing `/events` + `eventReceived`.
|
||||
4. Optionally bundle JetBrains Mono via `@fontsource/jetbrains-mono`.
|
||||
5. Dead tooling cleanup: remove `tslint.json`/codelyzer/`ng lint` script and
|
||||
the protractor e2e scaffolding, per the doc's §6.4 phase 5.
|
||||
6. Update `app.component.spec.ts` (currently asserts on the removed template)
|
||||
or delete it.
|
||||
|
||||
Acceptance: full manual QA (§7); PWA build works; no console errors.
|
||||
|
||||
## 5. Backend change summary (Phase 4 only)
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `app/ytdl.py` | `DownloadInfo.__init__`: set `thumbnail`, `duration` from `entry` |
|
||||
| `app/main.py` | add `GET /thumbnail?id=` route + cache dir; optional serialization trim |
|
||||
|
||||
Everything else in the backend (queue, precheck, conflict resolution,
|
||||
persistence, socket events, cookie handling) is untouched.
|
||||
|
||||
## 6. Out of scope
|
||||
|
||||
- Framework/stack change; backend queue/persistence rework.
|
||||
- Playlist monitor (separate planned effort).
|
||||
- Reveal-in-folder OS action (no backend support).
|
||||
- New download options; localization framework.
|
||||
- Publishing: when the redesign is merged, publish via `./publish.sh` per
|
||||
`AGENTS.md` (bumps the version in `DEPLOY.md` in the same commit).
|
||||
|
||||
## 7. Manual test checklist
|
||||
|
||||
Run through after Phase 3 (core) and again after Phase 5 (full):
|
||||
|
||||
- Add single URL and playlist URL; Enter key adds; invalid/empty URL toasts.
|
||||
- Quality/format chips persist across reload; folder picker persists; tag
|
||||
creation in picker works.
|
||||
- Cancel active download; cancel/delete queued; delete completed; retry
|
||||
failed; clear completed/failed; download selected files.
|
||||
- Batch: select 1 and many; floating bar actions on queued vs completed;
|
||||
move-to-folder batch moves files on disk (`POST /set_folder`).
|
||||
- Import URLs (with cancel), export, copy; cookie paste flow.
|
||||
- Filter by title; sort by date/title/size both directions; view toggle;
|
||||
select-all respects filter; counts in sidebar/stat strip match maps.
|
||||
- 230+ queued / 300+ completed: scrolling stays smooth, selection is
|
||||
responsive, thumbnails lazy-load, no layout jump while filtering.
|
||||
- Theme light/dark/auto persists and all surfaces are legible; PWA offline
|
||||
shell loads.
|
||||
- Mobile 375px and 768px: drawer navigation, no horizontal scroll, batch bar
|
||||
reachable, slide-over usable.
|
||||
|
||||
## 8. Effort estimate
|
||||
|
||||
| Phase | Estimate |
|
||||
|---|---|
|
||||
| 1. Foundations & split | 0.5–1 day |
|
||||
| 2. App shell & navigation | 1 day |
|
||||
| 3. List redesign | 1–1.5 days |
|
||||
| 4. Thumbnails (front + back) | 0.5 day |
|
||||
| 5. Polish & cleanup | 0.5 day |
|
||||
| **Total** | **~3.5–4.5 days** |
|
||||
|
||||
Phases 1–3 are the bulk of the UX gain; the mockup's folder sidebar removes
|
||||
the doc's main virtualization risk, so the list phase is more contained than
|
||||
originally estimated.
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
# MeTube UI Redesign — Design Doc
|
||||
|
||||
Date: 2026-08-09
|
||||
Status: Proposal (not yet implemented)
|
||||
|
||||
## 1. Background
|
||||
|
||||
MeTube (this fork) is a self-hosted web GUI for yt-dlp: Python 3.13 backend
|
||||
(`aiohttp` + `python-socketio`, ~2,000 LOC) and an Angular 19 frontend
|
||||
(Bootstrap 5.3 + ng-bootstrap, ~2,400 LOC, PWA).
|
||||
|
||||
The frontend is a single-screen app built around one monolithic component:
|
||||
|
||||
- `ui/src/app/app.component.ts` — 744 LOC
|
||||
- `ui/src/app/app.component.html` — 595 LOC, with the same table markup
|
||||
triplicated across three tabs
|
||||
- `ui/src/app/app.component.sass` — 341 LOC
|
||||
- Supporting: `downloads.service.ts` (socket/HTTP state), 2 checkbox
|
||||
components, 4 pipes, theme/format helpers
|
||||
|
||||
The UI is stock Bootstrap: three tabs (Downloading / Pending / Completed),
|
||||
each a 7-column `<table>`; an inline add-form; a collapsible "Advanced
|
||||
Options" card; `alert()` for errors; cookie-persisted preferences.
|
||||
|
||||
## 2. Problems with the current UI
|
||||
|
||||
1. **Table-based lists**: 7 text columns, horizontal `overflow-auto` scroll on
|
||||
mobile, no thumbnails — recognizing "which video is this" from text is slow.
|
||||
2. **No design language**: default Bootstrap chrome, no empty states, no
|
||||
skeleton loaders, "Connecting to server..." as the loading experience.
|
||||
3. **Errors via `alert()`**: blocking, non-actionable, disappears.
|
||||
4. **Monolithic component**: imperative `@ViewChild(...).nativeElement.disabled`
|
||||
manipulation (7+ instances), triplicated template markup, dead tooling
|
||||
(tslint/codelyzer/protractor). Every change is slower and riskier than it
|
||||
should be.
|
||||
5. **List tooling doesn't match real data volumes**: the primary user runs
|
||||
~230 queued / ~300 completed items. Plain scrolling tables are unusable at
|
||||
that scale — no filter, no sort, weak batch selection, no virtualization.
|
||||
|
||||
## 3. Decision: redesign in place, do NOT rewrite the stack
|
||||
|
||||
Evaluated and rejected: rewriting the frontend in Next.js (or Vite + React),
|
||||
and rewriting the backend in Node.
|
||||
|
||||
Rationale:
|
||||
|
||||
- ~90% of the user-visible benefit comes from the **design**, not the
|
||||
framework. Cards, thumbnails, filter/sort, batch actions, virtualization
|
||||
are all achievable in the existing Angular + Bootstrap stack (Angular CDK
|
||||
has virtual scrolling; Bootstrap renders cards fine).
|
||||
- The rewrite's only unique benefit is code health / developer velocity,
|
||||
which doesn't pay off for what is largely a one-off redesign.
|
||||
- Regression risk: cookie-persisted prefs, folder tag-creation, socket
|
||||
string-parsing quirks, PWA/service-worker setup would all need to be
|
||||
rediscovered and re-tested.
|
||||
- Next.js specifically is the wrong shape for this app: a LAN SPA with no
|
||||
SEO/SSR needs. If a rewrite ever happens, Vite + React is the right-sized
|
||||
tool — but it is not worth doing now.
|
||||
- The backend must stay Python regardless: yt-dlp is Python-native, and the
|
||||
subtle, battle-tested logic (precheck worker, filename-conflict resolution,
|
||||
multiprocess lifecycle, playlist expansion, cookie wiring) lives in
|
||||
`app/ytdl.py`. A Node port is 1.5–3 weeks of risk for zero user-visible gain.
|
||||
|
||||
If the frontend keeps needing substantial work after this redesign and Angular
|
||||
becomes a friction point, revisit a rewrite then — with a proven design to
|
||||
port, it becomes a mechanical ~2-day job.
|
||||
|
||||
## 4. Goals / Non-goals
|
||||
|
||||
Goals:
|
||||
|
||||
- Modern card-based, thumbnail-first UI for all three lists.
|
||||
- Handle 200–500-item lists comfortably: filter, sort, folder grouping,
|
||||
multi-select batch actions, virtualized rendering.
|
||||
- Keep "now downloading" always visible.
|
||||
- Replace `alert()` with toasts; add empty states and skeleton loading.
|
||||
- Mobile-friendly (cards stack; no horizontal table scroll).
|
||||
- Refactor the monolith into components as part of the work (only as much as
|
||||
the redesign requires — no gratuitous re-architecture).
|
||||
|
||||
Non-goals:
|
||||
|
||||
- No framework/stack change (stays Angular 19 + Bootstrap 5.3).
|
||||
- No backend queue/precheck/persistence changes, except the additive
|
||||
thumbnail endpoint (§6.3).
|
||||
- No new features beyond what the design requires (no new download options,
|
||||
no playlist monitor — that's a separate planned effort).
|
||||
|
||||
## 5. Proposed design
|
||||
|
||||
### 5.1 Layout (desktop)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ ▶ MeTube ⬇ 2 active · 12.4 MB/s 🌙 ⚙ │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 🔗 Paste video / playlist URL… [Add] │ │
|
||||
│ └──────────────────────────────────────────────────────────────────┘ │
|
||||
│ [Best][1080p][720p] [MP4▾][MP3][M4A] 📁 movies ⋯ Advanced │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ ── NOW DOWNLOADING (2) ───────────────────────────── 12.4 MB/s total ── │
|
||||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ [thumb] Title ███████████████░░░░░ 62% 12.4MB/s ETA 0:43 ✕ │ │
|
||||
│ │ [thumb] Title ████░░░░░░░░░░░░░░░ 18% 3.1MB/s ETA 4:12 ✕ │ │
|
||||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ [ Queued 230 ] [ Completed 303 ] 📁 All folders ▾ ▦ | ☰ │
|
||||
│ ┌───────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ ☑ Select 🔍 Filter titles… Sort: Date added ▾ Group: 📁 │ │
|
||||
│ ├───────────────────────────────────────────────────────────────────┤ │
|
||||
│ │ ▸ 📁 shows (86) ☐ all │ │
|
||||
│ │ ▸ 📁 music (54) ☐ all │ │
|
||||
│ │ ▾ 📁 movies (90) ☐ all │ │
|
||||
│ │ ☐ [thumb] Movie title one 720p · queued 2d ago ✕ │ │
|
||||
│ │ ☑ [thumb] Movie title two 720p · queued 2d ago ✕ │ │
|
||||
│ │ ☐ [thumb] Movie title three 720p · queued 3d ago ✕ │ │
|
||||
│ │ …(virtualized scroll)… │ │
|
||||
│ └───────────────────────────────────────────────────────────────────┘ │
|
||||
│ 2 selected ── [ ▶ Start ] [ ✕ Cancel ] [ 📁 Move to… ] │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.2 Advanced options slide-over
|
||||
|
||||
Replaces the inline collapsible card. Rarely-changed controls move here;
|
||||
|
||||
cookie-persisted defaults keep the main bar to one row.
|
||||
|
||||
```
|
||||
┌────────────────────────────┐
|
||||
│ Advanced options ✕ │
|
||||
│ │
|
||||
│ Auto-start [ toggle ]│
|
||||
│ Playlist strict [ toggle ]│
|
||||
│ Playlist limit [ 10 ] │
|
||||
│ │
|
||||
│ ── Batch ────────────── │
|
||||
│ [Import URLs] [Export] │
|
||||
│ [Copy all URLs] │
|
||||
│ │
|
||||
│ ── Cookies ──────────── │
|
||||
│ domain.com ✓ saved │
|
||||
│ [Paste cookie…] │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.3 Mobile
|
||||
|
||||
Cards stack vertically; the active panel, tabs, filter bar, and list reflow to
|
||||
single-column. No horizontal scrolling anywhere. The ⋮ menu and slide-over
|
||||
become bottom sheets.
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ ▶ MeTube 🌙 ⚙ │
|
||||
│ ⬇2 · 12MB/s │
|
||||
├─────────────────────────┤
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │🔗 Paste URL… [Add]│ │
|
||||
│ └─────────────────────┘ │
|
||||
│ [Best][1080p][720p] │
|
||||
│ 📁 movies ⋯ More │
|
||||
│ │
|
||||
│ NOW DOWNLOADING (2) │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │[thumb] │ │
|
||||
│ │ Title goes here │ │
|
||||
│ │ ████████░░░ 62% │ │
|
||||
│ │ 12MB/s · ETA 0:43 ✕ │ │
|
||||
│ └─────────────────────┘ │
|
||||
│ │
|
||||
│ [Queued 230][Done 303] │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ thumb │ │ thumb │ │
|
||||
│ │ title… │ │ title… │ │
|
||||
│ │ 384MB ⋮│ │ 1.2GB ⋮│ │
|
||||
│ └────────┘ └────────┘ │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.4 Key design decisions and rationale
|
||||
|
||||
1. **Tabs for the archives, pinned panel for active downloads.**
|
||||
Stacked sections (all three lists on one scrolling page) were considered
|
||||
and rejected: at 230/303 items the lists never fit on a screen, so the
|
||||
"pipeline at a glance" argument fails. Tabs hide state, so each tab carries
|
||||
a count badge. The active-downloads panel stays pinned above the tabs
|
||||
because its size is bounded by the concurrency limit (~3) — it costs
|
||||
nothing and keeps live progress always visible.
|
||||
|
||||
2. **Tables → thumbnail-first cards/rows.**
|
||||
Recognition-by-image is far faster than recognition-by-text; it also
|
||||
eliminates the 7-column mobile squeeze. Completed items get a grid/list
|
||||
toggle (▦|☰) — the completed tab is a media library (browse, play, move,
|
||||
delete), not an audit log.
|
||||
|
||||
3. **Command bar instead of a form.**
|
||||
Usage is 95% two actions: paste URL, watch progress. Quality/format become
|
||||
one-tap chips with cookie-persisted defaults; folder picker stays; prefix
|
||||
and everything else moves to the slide-over.
|
||||
|
||||
4. **List tooling matched to volume** (the main volume-driven change):
|
||||
- Per-tab filter box (title substring), sort (date added, title, size,
|
||||
folder), and folder grouping.
|
||||
- Folder groups are collapsible with per-group select-all.
|
||||
- Multi-select with a batch action bar (start / cancel / delete / move to
|
||||
folder). With 230 queued items the real operations are plural.
|
||||
- Virtualized scrolling (Angular CDK `cdk-virtual-scroll-viewport`) — render
|
||||
~30 rows instead of 533 DOM subtrees. Concrete perf win over the current
|
||||
tables.
|
||||
|
||||
5. **Toasts replace `alert()`** — non-blocking, can carry actions (e.g.
|
||||
"Duplicate skipped — view item"). The recent-events panel becomes a
|
||||
collapsible activity drawer (diagnostic info: available, not permanently
|
||||
occupying screen space).
|
||||
|
||||
6. **Theme**: keep the existing dark/light/auto Bootstrap `data-bs-theme`
|
||||
mechanism; refine palette to one accent color. Header gets slim stat chips
|
||||
(active count, total speed) replacing the current navbar counters.
|
||||
|
||||
### 5.5 Open question: per-folder navigation
|
||||
|
||||
If queue work is frequently per-folder ("start all the music ones"), folder
|
||||
groups may deserve first-class status — e.g. a qBittorrent-style left sidebar
|
||||
of folders with counts instead of group headers inside the list. Decide based
|
||||
on actual usage before implementation; default is the group-header design
|
||||
above (simpler, keeps single-column layout).
|
||||
|
||||
## 6. Implementation plan
|
||||
|
||||
### 6.1 Component refactor (Angular, in place)
|
||||
|
||||
Break the monolith into:
|
||||
|
||||
- `CommandBarComponent` — URL input, quality/format chips, folder picker,
|
||||
slide-over trigger. Owns the add-form state and cookie-persisted defaults.
|
||||
- `AdvancedOptionsComponent` — slide-over (auto-start, playlist options,
|
||||
import/export/copy, cookie paste).
|
||||
- `ActiveDownloadsComponent` — pinned now-downloading panel (rows + total
|
||||
speed).
|
||||
- `DownloadListComponent` — reusable list for Queued and Completed tabs:
|
||||
filter, sort, folder grouping, multi-select, batch action bar,
|
||||
`cdk-virtual-scroll-viewport`. Grid/list toggle applies to Completed.
|
||||
- `DownloadRowComponent` — thumbnail, title, progress/status line, folder
|
||||
badge, actions (cancel / start / move / delete / reveal).
|
||||
- `ToastsComponent` / toast service — replaces `alert()`.
|
||||
- Keep: `downloads.service.ts` (socket + HTTP state) largely as-is;
|
||||
`downloads.pipe.ts` (eta/speed/fileSize); theme helpers. The triplicated
|
||||
tab markup collapses into two instances of `DownloadListComponent`.
|
||||
|
||||
### 6.2 Frontend behaviors to preserve exactly
|
||||
|
||||
- Cookie-persisted prefs: quality, format, theme, active tab, sort, grouping.
|
||||
- Folder picker with tag-creation (currently ng-select) and per-row folder
|
||||
editing (calls `POST /set_folder`; moves completed files on disk).
|
||||
- Socket.IO event handling: events arrive as JSON *strings* and are parsed
|
||||
client-side; full state re-sync on connect via the `all` event.
|
||||
- PWA/service-worker setup (`custom-service-worker.js`, `ngsw-config.json`).
|
||||
- Import/Export/Copy URLs batch modal.
|
||||
- Existing HTTP API usage: `POST /add`, `POST /delete`, `POST /start`,
|
||||
`POST /set_folder`, `POST /cookie`, `GET /history`, `GET|POST /events`,
|
||||
`GET /version`.
|
||||
|
||||
### 6.3 Backend addition: thumbnail endpoint (additive only)
|
||||
|
||||
Cards need thumbnails. yt-dlp metadata already contains `thumbnail` URLs, but
|
||||
hot-linking remote thumbnails from a LAN app leaks requests to third-party
|
||||
hosts and breaks for expired URLs. Add one endpoint:
|
||||
|
||||
- `GET /thumbnail?id=<download_id>` — backend fetches/caches the thumbnail
|
||||
(cache under `STATE_DIR/thumbnails/`, keyed by download id), returns the
|
||||
image with long-lived cache headers. Fall back to a placeholder icon when
|
||||
no thumbnail exists (e.g. audio-only downloads).
|
||||
|
||||
No other backend changes. Queue, precheck, conflict resolution, persistence,
|
||||
socket events are untouched.
|
||||
|
||||
### 6.4 Phasing
|
||||
|
||||
1. **Phase 1 — component split** (no visual change): extract components from
|
||||
the monolith, keep the tables. Establishes the structure the redesign
|
||||
builds on; low regression risk, easily reviewable.
|
||||
2. **Phase 2 — list redesign**: `DownloadListComponent` with cards, filter,
|
||||
sort, folder groups, multi-select, virtualization; pinned active panel;
|
||||
tabs with count badges.
|
||||
3. **Phase 3 — command bar + slide-over**: new add flow, toasts, activity
|
||||
drawer, empty states, skeletons.
|
||||
4. **Phase 4 — thumbnails**: backend endpoint + wire into rows/grid.
|
||||
5. **Phase 5 — polish**: palette/accent, mobile bottom sheets, cleanup of dead
|
||||
tooling (tslint/codelyzer/protractor configs) if desired.
|
||||
|
||||
Phases 1–3 deliver the bulk of the UX gain; Phase 4 is the biggest visual
|
||||
upgrade but is separable if backend changes need to wait.
|
||||
|
||||
### 6.5 Effort estimate
|
||||
|
||||
| Phase | Estimate |
|
||||
|---|---|
|
||||
| 1. Component split | 0.5–1 day |
|
||||
| 2. List redesign | 1–1.5 days |
|
||||
| 3. Command bar + toasts | 0.5–1 day |
|
||||
| 4. Thumbnails (front + back) | 0.5 day |
|
||||
| 5. Polish | 0.5 day |
|
||||
| **Total** | **~3–4 days** |
|
||||
|
||||
(For reference, the rejected full rewrite in React was estimated at 3–5 days
|
||||
for the frontend alone, plus regression risk on working behaviors.)
|
||||
|
||||
## 7. Risks
|
||||
|
||||
- **Virtual scroll + folder groups**: `cdk-virtual-scroll-viewport` works best
|
||||
with flat lists; collapsible groups need flattening logic in the component.
|
||||
Mitigation: flatten grouped data into a single array with group-header rows
|
||||
(standard pattern).
|
||||
- **Thumbnail fetching load**: 300 completed items loading thumbnails at once.
|
||||
Mitigation: backend cache + lazy loading (`loading="lazy"` / viewport-based).
|
||||
- **Bootstrap theming limits**: the refined palette may fight Bootstrap
|
||||
defaults. Mitigation: CSS custom properties on top of `data-bs-theme`,
|
||||
not a Bootstrap rebuild.
|
||||
- **Regression in preserved behaviors** (§6.2): mitigate with manual test
|
||||
checklist per phase; the project has no real frontend test suite, and
|
||||
adding one is out of scope.
|
||||
67
app/main.py
67
app/main.py
|
|
@ -15,6 +15,8 @@ import json
|
|||
import pathlib
|
||||
import re
|
||||
import base64
|
||||
import hashlib
|
||||
import aiohttp
|
||||
from urllib.parse import urlparse
|
||||
from watchfiles import DefaultFilter, Change, awatch
|
||||
|
||||
|
|
@ -332,6 +334,71 @@ async def set_cookie(request):
|
|||
'msg': f'Failed to save cookie: {str(e)}'
|
||||
}))
|
||||
|
||||
PLACEHOLDER_THUMB = '''<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180">
|
||||
<rect width="320" height="180" fill="#e8eaed"/>
|
||||
<g fill="none" stroke="#9aa0a6" stroke-width="6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="125" y="60" width="70" height="60" rx="8"/>
|
||||
<polyline points="150 75 165 90 150 105"/>
|
||||
</g>
|
||||
</svg>'''
|
||||
|
||||
|
||||
def sniff_image_type(data: bytes) -> str:
|
||||
if data[:3] == b'\xff\xd8\xff':
|
||||
return 'image/jpeg'
|
||||
if data[:8] == b'\x89PNG\r\n\x1a\n':
|
||||
return 'image/png'
|
||||
if data[:4] == b'GIF8':
|
||||
return 'image/gif'
|
||||
if data[:4] == b'RIFF' and data[8:12] == b'WEBP':
|
||||
return 'image/webp'
|
||||
if data[:12] == b'\x00\x00\x00\x1cftyp':
|
||||
return 'image/avif'
|
||||
return 'application/octet-stream'
|
||||
|
||||
|
||||
@routes.get(config.URL_PREFIX + 'thumbnail')
|
||||
async def thumbnail(request):
|
||||
"""Serve a cached copy of a download's remote thumbnail.
|
||||
|
||||
The URL is taken from stored yt-dlp metadata for the given download key —
|
||||
clients can never point this endpoint at arbitrary URLs. Missing/invalid
|
||||
thumbnails fall back to a placeholder SVG.
|
||||
"""
|
||||
key = request.query.get('id')
|
||||
if not key:
|
||||
raise web.HTTPBadRequest()
|
||||
|
||||
info = dqueue.find_info(key)
|
||||
if info is None or not info.thumbnail:
|
||||
return web.Response(text=PLACEHOLDER_THUMB, content_type='image/svg+xml', headers={'Cache-Control': 'public, max-age=3600'})
|
||||
|
||||
thumb_dir = os.path.join(config.STATE_DIR, 'thumbnails')
|
||||
os.makedirs(thumb_dir, exist_ok=True)
|
||||
thumb_path = os.path.join(thumb_dir, hashlib.sha1(key.encode('utf-8')).hexdigest() + '.img')
|
||||
|
||||
if not os.path.exists(thumb_path):
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=15)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(info.thumbnail, headers={'User-Agent': 'Mozilla/5.0 MeTube'}) as resp:
|
||||
if resp.status != 200:
|
||||
return web.Response(text=PLACEHOLDER_THUMB, content_type='image/svg+xml', headers={'Cache-Control': 'public, max-age=3600'})
|
||||
data = await resp.read()
|
||||
with open(thumb_path, 'wb') as f:
|
||||
f.write(data)
|
||||
except Exception as exc: # noqa: BLE001 - fall back to placeholder on any fetch error
|
||||
log.warning(f"Failed to fetch thumbnail for {key}: {exc}")
|
||||
return web.Response(text=PLACEHOLDER_THUMB, content_type='image/svg+xml', headers={'Cache-Control': 'public, max-age=3600'})
|
||||
|
||||
with open(thumb_path, 'rb') as f:
|
||||
data = f.read()
|
||||
return web.Response(
|
||||
body=data,
|
||||
content_type=sniff_image_type(data[:16]),
|
||||
headers={'Cache-Control': 'public, max-age=31536000, immutable'}
|
||||
)
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'delete')
|
||||
async def delete(request):
|
||||
post = await request.json()
|
||||
|
|
|
|||
16
app/ytdl.py
16
app/ytdl.py
|
|
@ -40,6 +40,7 @@ class DownloadQueueNotifier:
|
|||
|
||||
class DownloadInfo:
|
||||
def __init__(self, id, title, url, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit):
|
||||
entry = entry or {}
|
||||
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}'
|
||||
self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
|
||||
self.url = url
|
||||
|
|
@ -54,6 +55,11 @@ class DownloadInfo:
|
|||
self.error = error
|
||||
self.entry = entry
|
||||
self.playlist_item_limit = playlist_item_limit
|
||||
# Thumbnail + duration for the redesigned thumbnail-first UI.
|
||||
# Prefer the largest thumbnail entry, falling back to 'thumbnail'.
|
||||
thumbnails = entry.get('thumbnails') or []
|
||||
self.thumbnail = entry.get('thumbnail') or (thumbnails[-1].get('url') if thumbnails else None)
|
||||
self.duration = entry.get('duration')
|
||||
# Extract website domain from URL
|
||||
parsed_url = urlparse(url)
|
||||
self.website = parsed_url.netloc
|
||||
|
|
@ -380,6 +386,16 @@ class DownloadQueue:
|
|||
|
||||
self.done.load()
|
||||
|
||||
def find_info(self, key):
|
||||
"""Look up a DownloadInfo across pending/queue/done by URL key."""
|
||||
for store in (self.pending, self.queue, self.done):
|
||||
if key in store.dict:
|
||||
return store.dict[key].info
|
||||
for k, v in store.saved_items():
|
||||
if k == key:
|
||||
return v
|
||||
return None
|
||||
|
||||
async def __import_queue(self):
|
||||
for k, v in self.queue.saved_items():
|
||||
await self.__add_download(v, True)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -87,50 +87,6 @@
|
|||
"options": {
|
||||
"buildTarget": "metube:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"main": "src/test.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"karmaConfig": "karma.conf.js",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
"src/manifest.webmanifest",
|
||||
"src/custom-service-worker.js"
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.sass"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-devkit/build-angular:tslint",
|
||||
"options": {
|
||||
"tsConfig": [
|
||||
"tsconfig.app.json",
|
||||
"tsconfig.spec.json",
|
||||
"e2e/tsconfig.json"
|
||||
],
|
||||
"exclude": [
|
||||
"**/node_modules/**"
|
||||
]
|
||||
}
|
||||
},
|
||||
"e2e": {
|
||||
"builder": "@angular-devkit/build-angular:protractor",
|
||||
"options": {
|
||||
"protractorConfig": "e2e/protractor.conf.js",
|
||||
"devServerTarget": "metube:serve"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"devServerTarget": "metube:serve:production"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
// @ts-check
|
||||
// Protractor configuration file, see link for more information
|
||||
// https://github.com/angular/protractor/blob/master/lib/config.ts
|
||||
|
||||
const { SpecReporter, StacktraceOption } = require('jasmine-spec-reporter');
|
||||
|
||||
/**
|
||||
* @type { import("protractor").Config }
|
||||
*/
|
||||
exports.config = {
|
||||
allScriptsTimeout: 11000,
|
||||
specs: [
|
||||
'./src/**/*.e2e-spec.ts'
|
||||
],
|
||||
capabilities: {
|
||||
browserName: 'chrome'
|
||||
},
|
||||
directConnect: true,
|
||||
baseUrl: 'http://localhost:4200/',
|
||||
framework: 'jasmine',
|
||||
jasmineNodeOpts: {
|
||||
showColors: true,
|
||||
defaultTimeoutInterval: 30000,
|
||||
print: function() {}
|
||||
},
|
||||
onPrepare() {
|
||||
require('ts-node').register({
|
||||
project: require('path').join(__dirname, './tsconfig.json')
|
||||
});
|
||||
jasmine.getEnv().addReporter(new SpecReporter({
|
||||
spec: {
|
||||
displayStacktrace: StacktraceOption.PRETTY
|
||||
}
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { AppPage } from './app.po';
|
||||
import { browser, logging } from 'protractor';
|
||||
|
||||
describe('workspace-project App', () => {
|
||||
let page: AppPage;
|
||||
|
||||
beforeEach(() => {
|
||||
page = new AppPage();
|
||||
});
|
||||
|
||||
it('should display welcome message', () => {
|
||||
page.navigateTo();
|
||||
expect(page.getTitleText()).toEqual('metube app is running!');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Assert that there are no errors emitted from the browser
|
||||
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
|
||||
expect(logs).not.toContain(jasmine.objectContaining({
|
||||
level: logging.Level.SEVERE,
|
||||
} as logging.Entry));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { browser, by, element } from 'protractor';
|
||||
|
||||
export class AppPage {
|
||||
navigateTo(): Promise<unknown> {
|
||||
return browser.get(browser.baseUrl) as Promise<unknown>;
|
||||
}
|
||||
|
||||
getTitleText(): Promise<string> {
|
||||
return element(by.css('app-root .content span')).getText() as Promise<string>;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../out-tsc/e2e",
|
||||
"module": "commonjs",
|
||||
"target": "es2018",
|
||||
"types": [
|
||||
"jasmine",
|
||||
"jasminewd2",
|
||||
"node"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
// Karma configuration file, see link for more information
|
||||
// https://karma-runner.github.io/1.0/config/configuration-file.html
|
||||
|
||||
module.exports = function (config) {
|
||||
config.set({
|
||||
basePath: '',
|
||||
frameworks: ['jasmine', '@angular-devkit/build-angular'],
|
||||
plugins: [
|
||||
require('karma-jasmine'),
|
||||
require('karma-chrome-launcher'),
|
||||
require('karma-jasmine-html-reporter'),
|
||||
require('karma-coverage-istanbul-reporter'),
|
||||
require('@angular-devkit/build-angular/plugins/karma')
|
||||
],
|
||||
client: {
|
||||
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
||||
},
|
||||
coverageIstanbulReporter: {
|
||||
dir: require('path').join(__dirname, './coverage/metube'),
|
||||
reports: ['html', 'lcovonly', 'text-summary'],
|
||||
fixWebpackSourcePaths: true
|
||||
},
|
||||
reporters: ['progress', 'kjhtml'],
|
||||
port: 9876,
|
||||
colors: true,
|
||||
logLevel: config.LOG_INFO,
|
||||
autoWatch: true,
|
||||
browsers: ['Chrome'],
|
||||
singleRun: false,
|
||||
restartOnFileChange: true
|
||||
});
|
||||
};
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^19.2.14",
|
||||
"@angular/cdk": "^19.2.19",
|
||||
"@angular/common": "^19.2.14",
|
||||
"@angular/compiler": "^19.2.14",
|
||||
"@angular/core": "^19.2.14",
|
||||
|
|
@ -37,9 +38,7 @@
|
|||
"@angular/cli": "^19.2.14",
|
||||
"@angular/compiler-cli": "^19.2.14",
|
||||
"@types/node": "^22.15.29",
|
||||
"codelyzer": "^6.0.2",
|
||||
"ts-node": "~10.9.1",
|
||||
"tslint": "~6.1.3",
|
||||
"typescript": "~5.8.3"
|
||||
}
|
||||
},
|
||||
|
|
@ -417,6 +416,21 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/cdk": {
|
||||
"version": "19.2.19",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.2.19.tgz",
|
||||
"integrity": "sha512-PCpJagurPBqciqcq4Z8+3OtKLb7rSl4w/qBJoIMua8CgnrjvA1i+SWawhdtfI1zlY8FSwhzLwXV0CmWWfFzQPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parse5": "^7.1.2",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^19.0.0 || ^20.0.0",
|
||||
"@angular/core": "^19.0.0 || ^20.0.0",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/cli": {
|
||||
"version": "19.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.17.tgz",
|
||||
|
|
@ -5883,16 +5897,6 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/app-root-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz",
|
||||
"integrity": "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
|
|
@ -5907,17 +5911,6 @@
|
|||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-3.0.0.tgz",
|
||||
"integrity": "sha512-majUxHgLehQTeSA+hClx+DY09OVUqG3GtezWkF1krgLGNdlDu9l9V8DaqNMWbq4Eddc8wsyDA0hpDUtnYxQEXw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ast-types-flow": "0.0.7",
|
||||
"commander": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
|
|
@ -5925,13 +5918,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ast-types-flow": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz",
|
||||
"integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.20",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
|
||||
|
|
@ -5970,16 +5956,6 @@
|
|||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.0.2.tgz",
|
||||
"integrity": "sha512-MCeek8ZH7hKyO1rWUbKNQBbl4l2eY0ntk7OGi+q0RlafrCnfPxC06WZA+uebCfmYp4mNU9jRBP1AhGyf8+W3ww==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ast-types-flow": "0.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-loader": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz",
|
||||
|
|
@ -6337,16 +6313,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/builtin-modules": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz",
|
||||
"integrity": "sha512-wxXCdllwGhI2kCC0MnvTGYTMvnVZTvqgypkiTI8Pa5tcz2i6VqsqwYGgqwXji+4RgCzms6EajE4IxiUH6HH8nQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bundle-name": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
|
||||
|
|
@ -6736,93 +6702,6 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/codelyzer": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/codelyzer/-/codelyzer-6.0.2.tgz",
|
||||
"integrity": "sha512-v3+E0Ucu2xWJMOJ2fA/q9pDT/hlxHftHGPUay1/1cTgyPV5JTHFdO9hqo837Sx2s9vKBMTt5gO+lhF95PO6J+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@angular/compiler": "9.0.0",
|
||||
"@angular/core": "9.0.0",
|
||||
"app-root-path": "^3.0.0",
|
||||
"aria-query": "^3.0.0",
|
||||
"axobject-query": "2.0.2",
|
||||
"css-selector-tokenizer": "^0.7.1",
|
||||
"cssauron": "^1.4.0",
|
||||
"damerau-levenshtein": "^1.0.4",
|
||||
"rxjs": "^6.5.3",
|
||||
"semver-dsl": "^1.0.1",
|
||||
"source-map": "^0.5.7",
|
||||
"sprintf-js": "^1.1.2",
|
||||
"tslib": "^1.10.0",
|
||||
"zone.js": "~0.10.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/compiler": ">=2.3.1 <13.0.0 || ^12.0.0-next || ^12.1.0-next || ^12.2.0-next",
|
||||
"@angular/core": ">=2.3.1 <13.0.0 || ^12.0.0-next || ^12.1.0-next || ^12.2.0-next",
|
||||
"tslint": "^5.0.0 || ^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/codelyzer/node_modules/@angular/compiler": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-9.0.0.tgz",
|
||||
"integrity": "sha512-ctjwuntPfZZT2mNj2NDIVu51t9cvbhl/16epc5xEwyzyDt76pX9UgwvY+MbXrf/C/FWwdtmNtfP698BKI+9leQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"tslib": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/codelyzer/node_modules/@angular/core": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-9.0.0.tgz",
|
||||
"integrity": "sha512-6Pxgsrf0qF9iFFqmIcWmjJGkkCaCm6V5QNnxMy2KloO3SDq6QuMVRbN9RtC8Urmo25LP+eZ6ZgYqFYpdD8Hd9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"rxjs": "^6.5.3",
|
||||
"tslib": "^1.10.0",
|
||||
"zone.js": "~0.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/codelyzer/node_modules/rxjs": {
|
||||
"version": "6.6.7",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz",
|
||||
"integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/codelyzer/node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/codelyzer/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/codelyzer/node_modules/zone.js": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.10.3.tgz",
|
||||
"integrity": "sha512-LXVLVEq0NNOqK/fLJo3d0kfzd4sxwn2/h67/02pjCjfKDxgx1i9QqpvtHD8CrBnSSwMw5+dy11O7FRX5mkO7Cg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
|
|
@ -6921,13 +6800,6 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/connect-history-api-fallback": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz",
|
||||
|
|
@ -7191,17 +7063,6 @@
|
|||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-selector-tokenizer": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.7.3.tgz",
|
||||
"integrity": "sha512-jWQv3oCEL5kMErj4wRnK/OPoBi0D+P1FR2cDCKYPaMeD2eW3/mttav8HT4hT1CKopiJI/psEULjkClhvJo4Lvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"fastparse": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||
|
|
@ -7215,16 +7076,6 @@
|
|||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/cssauron": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/cssauron/-/cssauron-1.4.0.tgz",
|
||||
"integrity": "sha512-Ht70DcFBh+/ekjVrYS2PlDMdSQEl3OFNmjK6lcn49HptBgilXf/Zwg4uFh9Xn0pX3Q8YOkSjIFOfK2osvdqpBw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"through": "X.X.X"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
|
|
@ -7238,13 +7089,6 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
"integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -7793,16 +7637,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-scope": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
|
||||
|
|
@ -7817,20 +7651,6 @@
|
|||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/esrecurse": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
|
||||
|
|
@ -8034,13 +7854,6 @@
|
|||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fastparse": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz",
|
||||
"integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
|
|
@ -8258,13 +8071,6 @@
|
|||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
|
|
@ -8832,18 +8638,6 @@
|
|||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
|
|
@ -9892,16 +9686,6 @@
|
|||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
|
|
@ -10546,16 +10330,6 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/onetime": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
|
||||
|
|
@ -10861,7 +10635,6 @@
|
|||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
|
|
@ -10902,7 +10675,6 @@
|
|||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
|
|
@ -10931,16 +10703,6 @@
|
|||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
|
|
@ -11860,26 +11622,6 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/semver-dsl": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/semver-dsl/-/semver-dsl-1.0.1.tgz",
|
||||
"integrity": "sha512-e8BOaTo007E3dMuQQTnPdalbKTABKNS7UxoBIDnwOqRa+QwMrCPjynB8zAlPF6xlqUfdLPPLIJ13hJNmhtq8Ng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^5.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/semver-dsl/node_modules/semver": {
|
||||
"version": "5.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
|
||||
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
||||
|
|
@ -12570,13 +12312,6 @@
|
|||
"wbuf": "^1.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ssri": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz",
|
||||
|
|
@ -12933,13 +12668,6 @@
|
|||
"tslib": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/through": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
||||
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/thunky": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
|
||||
|
|
@ -13076,233 +12804,6 @@
|
|||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tslint": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tslint/-/tslint-6.1.3.tgz",
|
||||
"integrity": "sha512-IbR4nkT96EQOvKE2PW/djGz8iGNeJ4rF2mBfiYaR/nvUWYKJhLwimoJKgjIFEIDibBtOevj7BqCRL4oHeWWUCg==",
|
||||
"deprecated": "TSLint has been deprecated in favor of ESLint. Please see https://github.com/palantir/tslint/issues/4534 for more information.",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.0.0",
|
||||
"builtin-modules": "^1.1.1",
|
||||
"chalk": "^2.3.0",
|
||||
"commander": "^2.12.1",
|
||||
"diff": "^4.0.1",
|
||||
"glob": "^7.1.1",
|
||||
"js-yaml": "^3.13.1",
|
||||
"minimatch": "^3.0.4",
|
||||
"mkdirp": "^0.5.3",
|
||||
"resolve": "^1.3.2",
|
||||
"semver": "^5.3.0",
|
||||
"tslib": "^1.13.0",
|
||||
"tsutils": "^2.29.0"
|
||||
},
|
||||
"bin": {
|
||||
"tslint": "bin/tslint"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=2.3.0-dev || >=2.4.0-dev || >=2.5.0-dev || >=2.6.0-dev || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >=3.0.0-dev || >= 3.1.0-dev || >= 3.2.0-dev || >= 4.0.0-dev"
|
||||
}
|
||||
},
|
||||
"node_modules/tslint/node_modules/ansi-styles": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
|
||||
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/tslint/node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tslint/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tslint/node_modules/chalk": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
||||
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^3.2.1",
|
||||
"escape-string-regexp": "^1.0.5",
|
||||
"supports-color": "^5.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/tslint/node_modules/color-convert": {
|
||||
"version": "1.9.3",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tslint/node_modules/color-name": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
|
||||
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslint/node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/tslint/node_modules/has-flag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/tslint/node_modules/js-yaml": {
|
||||
"version": "3.14.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
|
||||
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tslint/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/tslint/node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tslint/node_modules/semver": {
|
||||
"version": "5.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
|
||||
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver"
|
||||
}
|
||||
},
|
||||
"node_modules/tslint/node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/tslint/node_modules/supports-color": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/tslint/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsutils": {
|
||||
"version": "2.29.0",
|
||||
"resolved": "https://registry.npmjs.org/tsutils/-/tsutils-2.29.0.tgz",
|
||||
"integrity": "sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^1.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=2.1.0 || >=2.1.0-dev || >=2.2.0-dev || >=2.3.0-dev || >=2.4.0-dev || >=2.5.0-dev || >=2.6.0-dev || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >= 3.0.0-dev || >= 3.1.0-dev"
|
||||
}
|
||||
},
|
||||
"node_modules/tsutils/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tuf-js": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-3.1.0.tgz",
|
||||
|
|
@ -14470,13 +13971,6 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
||||
|
|
|
|||
|
|
@ -4,14 +4,12 @@
|
|||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"test": "ng test",
|
||||
"lint": "ng lint",
|
||||
"e2e": "ng e2e"
|
||||
"build": "ng build"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^19.2.14",
|
||||
"@angular/cdk": "^19.2.19",
|
||||
"@angular/common": "^19.2.14",
|
||||
"@angular/compiler": "^19.2.14",
|
||||
"@angular/core": "^19.2.14",
|
||||
|
|
@ -40,9 +38,7 @@
|
|||
"@angular/cli": "^19.2.14",
|
||||
"@angular/compiler-cli": "^19.2.14",
|
||||
"@types/node": "^22.15.29",
|
||||
"codelyzer": "^6.0.2",
|
||||
"ts-node": "~10.9.1",
|
||||
"tslint": "~6.1.3",
|
||||
"typescript": "~5.8.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
<section class="active-panel">
|
||||
<div class="section-head">
|
||||
<h2 class="section-title">正在下载</h2>
|
||||
<span class="section-meta num" *ngIf="etaHint">{{ totalSpeed | speed }} · {{ etaHint }}</span>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="!loading; else skeletonTpl">
|
||||
<div class="active-list" *ngIf="items.length; else emptyTpl">
|
||||
<div class="active-row" *ngFor="let item of items; trackBy: trackByKey">
|
||||
<div class="thumb">
|
||||
<img *ngIf="item.value.thumbnail" [src]="thumbUrl(item)" [alt]="item.value.title" loading="lazy">
|
||||
<span class="thumb-ph" *ngIf="!item.value.thumbnail">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="10 9 15 12 10 15"/></svg>
|
||||
</span>
|
||||
<span class="dur num" *ngIf="item.value.duration">{{ item.value.duration | duration }}</span>
|
||||
</div>
|
||||
<div class="active-main">
|
||||
<div class="active-title">{{ item.value.title }}</div>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" [class.indeterminate]="isPreparing(item)" [style.width.%]="isPreparing(item) ? 100 : (item.value.percent || 0)"></div>
|
||||
</div>
|
||||
<div class="active-stats">
|
||||
<span>
|
||||
<span class="num">{{ isPreparing(item) ? '—' : ((item.value.percent || 0) | number:'1.0-0') }}%</span>
|
||||
<span> · {{ item.value.size ? (item.value.size | fileSize) : '—' }}</span>
|
||||
</span>
|
||||
<span class="num">{{ item.value.speed | speed }}</span>
|
||||
<span>ETA <span class="num">{{ item.value.eta | eta }}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<button class="icon-btn" (click)="cancel.emit(item.key)" title="取消下载" aria-label="取消下载">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #emptyTpl>
|
||||
<div class="empty">
|
||||
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<div class="empty-title">当前没有正在下载的任务</div>
|
||||
<div class="empty-sub">添加 URL 后,下载进度会固定显示在这里</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #skeletonTpl>
|
||||
<div class="active-list">
|
||||
<div class="active-row skeleton" *ngFor="let _ of [0, 1]">
|
||||
<div class="thumb shimmer"></div>
|
||||
<div class="active-main">
|
||||
<div class="shimmer line w60"></div>
|
||||
<div class="shimmer line w90"></div>
|
||||
<div class="shimmer line w40"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
:host
|
||||
display: block
|
||||
|
||||
.section-head
|
||||
display: flex
|
||||
align-items: baseline
|
||||
gap: 10px
|
||||
margin-bottom: 8px
|
||||
|
||||
.section-title
|
||||
font-family: var(--font-display)
|
||||
font-size: 15px
|
||||
font-weight: 600
|
||||
letter-spacing: -0.01em
|
||||
line-height: 1.35
|
||||
margin: 0
|
||||
|
||||
.section-meta
|
||||
font-size: 12px
|
||||
color: var(--muted)
|
||||
font-family: var(--font-mono)
|
||||
|
||||
.active-list
|
||||
display: grid
|
||||
gap: 8px
|
||||
|
||||
.active-row
|
||||
display: grid
|
||||
grid-template-columns: 120px 1fr auto
|
||||
gap: 14px
|
||||
align-items: center
|
||||
background: var(--surface)
|
||||
border: 1px solid var(--border)
|
||||
border-radius: var(--radius)
|
||||
padding: 10px 14px 10px 10px
|
||||
|
||||
.thumb
|
||||
border-radius: 5px
|
||||
overflow: hidden
|
||||
background: var(--placeholder)
|
||||
position: relative
|
||||
flex: none
|
||||
aspect-ratio: 16 / 9
|
||||
display: grid
|
||||
place-items: center
|
||||
|
||||
img
|
||||
display: block
|
||||
width: 100%
|
||||
height: 100%
|
||||
object-fit: cover
|
||||
|
||||
.thumb-ph
|
||||
color: var(--muted)
|
||||
|
||||
.dur
|
||||
position: absolute
|
||||
right: 4px
|
||||
bottom: 4px
|
||||
background: oklch(20% 0.02 240 / 0.82)
|
||||
color: oklch(96% 0.01 240)
|
||||
font-family: var(--font-mono)
|
||||
font-size: 10.5px
|
||||
padding: 1px 5px
|
||||
border-radius: 3px
|
||||
|
||||
.active-main
|
||||
min-width: 0
|
||||
|
||||
.active-title
|
||||
font-size: 13.5px
|
||||
font-weight: 550
|
||||
line-height: 1.4
|
||||
margin-bottom: 6px
|
||||
display: -webkit-box
|
||||
-webkit-line-clamp: 1
|
||||
-webkit-box-orient: vertical
|
||||
overflow: hidden
|
||||
|
||||
.progress-track
|
||||
height: 5px
|
||||
border-radius: 3px
|
||||
background: oklch(93% 0.008 240)
|
||||
overflow: hidden
|
||||
margin-bottom: 6px
|
||||
|
||||
[data-bs-theme="dark"] .progress-track
|
||||
background: oklch(28% 0.014 250)
|
||||
|
||||
.progress-fill
|
||||
height: 100%
|
||||
border-radius: 3px
|
||||
background: var(--accent)
|
||||
transition: width 0.6s ease
|
||||
|
||||
&.indeterminate
|
||||
background: linear-gradient(90deg, var(--accent-tint), var(--accent), var(--accent-tint))
|
||||
background-size: 200% 100%
|
||||
animation: slide 1.2s linear infinite
|
||||
|
||||
@keyframes slide
|
||||
from
|
||||
background-position: 200% 0
|
||||
to
|
||||
background-position: -200% 0
|
||||
|
||||
.active-stats
|
||||
display: flex
|
||||
gap: 14px
|
||||
font-size: 12px
|
||||
color: var(--muted)
|
||||
flex-wrap: wrap
|
||||
|
||||
.num
|
||||
color: var(--fg)
|
||||
|
||||
.icon-btn
|
||||
width: 32px
|
||||
height: 32px
|
||||
display: grid
|
||||
place-items: center
|
||||
border: 1px solid transparent
|
||||
background: none
|
||||
border-radius: 6px
|
||||
color: var(--muted)
|
||||
transition: background 0.12s ease, color 0.12s ease
|
||||
|
||||
&:hover
|
||||
background: var(--danger-tint)
|
||||
color: var(--danger)
|
||||
|
||||
.empty
|
||||
text-align: center
|
||||
padding: 14px 20px
|
||||
border: 1px dashed var(--border)
|
||||
border-radius: var(--radius)
|
||||
background: var(--surface)
|
||||
|
||||
svg
|
||||
color: var(--muted)
|
||||
margin-bottom: 4px
|
||||
|
||||
.empty-title
|
||||
font-size: 14px
|
||||
font-weight: 600
|
||||
margin-bottom: 4px
|
||||
|
||||
.empty-sub
|
||||
font-size: 12.5px
|
||||
color: var(--muted)
|
||||
|
||||
.skeleton
|
||||
.shimmer
|
||||
background: linear-gradient(90deg, var(--placeholder) 25%, oklch(90% 0.008 240) 50%, var(--placeholder) 75%)
|
||||
background-size: 200% 100%
|
||||
animation: shimmer 1.4s ease infinite
|
||||
|
||||
.line
|
||||
height: 10px
|
||||
border-radius: 5px
|
||||
margin-bottom: 6px
|
||||
|
||||
&.w60
|
||||
width: 60%
|
||||
|
||||
&.w90
|
||||
width: 90%
|
||||
|
||||
&.w40
|
||||
width: 40%
|
||||
|
||||
@keyframes shimmer
|
||||
from
|
||||
background-position: 200% 0
|
||||
to
|
||||
background-position: -200% 0
|
||||
|
||||
@media (max-width: 900px)
|
||||
.active-row
|
||||
grid-template-columns: 96px 1fr
|
||||
|
||||
.row-actions
|
||||
grid-column: 2
|
||||
justify-self: end
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { Download } from './downloads.service';
|
||||
import { DownloadsService } from './downloads.service';
|
||||
|
||||
export interface KeyedDownload {
|
||||
key: string;
|
||||
value: Download;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-active-downloads',
|
||||
standalone: false,
|
||||
templateUrl: './active-downloads.component.html',
|
||||
styleUrls: ['./active-downloads.component.sass']
|
||||
})
|
||||
export class ActiveDownloadsComponent {
|
||||
@Input() items: KeyedDownload[] = [];
|
||||
@Input() totalSpeed = 0;
|
||||
@Input() loading = false;
|
||||
@Output() cancel = new EventEmitter<string>();
|
||||
|
||||
constructor(private downloads: DownloadsService) {}
|
||||
|
||||
trackByKey(_: number, item: KeyedDownload): string {
|
||||
return item.key;
|
||||
}
|
||||
|
||||
thumbUrl(item: KeyedDownload): string {
|
||||
return this.downloads.thumbnailUrl(item.key);
|
||||
}
|
||||
|
||||
get etaHint(): string {
|
||||
const etas = this.items.map(i => i.value.eta).filter((e): e is number => e !== null && e !== undefined && e > 0);
|
||||
if (!etas.length) return '';
|
||||
const min = Math.min(...etas);
|
||||
const h = Math.floor(min / 3600);
|
||||
const m = Math.floor((min % 3600) / 60);
|
||||
const s = Math.round(min % 60);
|
||||
return h > 0 ? `预计 ${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')} 完成首个任务` : `预计 ${m}:${String(s).padStart(2, '0')} 完成首个任务`;
|
||||
}
|
||||
|
||||
isPreparing(item: KeyedDownload): boolean {
|
||||
return item.value.status === 'preparing';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<div class="scrim" [class.show]="open" (click)="close.emit()"></div>
|
||||
<aside class="slideover" [class.show]="open" aria-label="高级选项">
|
||||
<div class="so-head">
|
||||
<span class="so-title">高级选项</span>
|
||||
<button class="icon-btn neutral" (click)="close.emit()" style="margin-left:auto" aria-label="关闭">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="so-body">
|
||||
<div class="so-group">
|
||||
<div class="so-label">下载行为</div>
|
||||
<div class="so-row">
|
||||
<div>自动开始<div class="sub">添加后立即开始下载</div></div>
|
||||
<button class="switch" [class.on]="autoStart" role="switch" [attr.aria-checked]="autoStart" (click)="autoStartChange.emit(!autoStart)"></button>
|
||||
</div>
|
||||
<div class="so-row">
|
||||
<div>严格播放列表<div class="sub">仅下载完全匹配的播放列表项</div></div>
|
||||
<button class="switch" [class.on]="playlistStrictMode" role="switch" [attr.aria-checked]="playlistStrictMode" (click)="strictChange.emit(!playlistStrictMode)"></button>
|
||||
</div>
|
||||
<div class="so-row">
|
||||
<div>播放列表上限<div class="sub">单次最多展开的条目数(0 = 不限制)</div></div>
|
||||
<input class="so-input num" type="number" min="0" [ngModel]="playlistItemLimit" (ngModelChange)="limitChange.emit($event)">
|
||||
</div>
|
||||
<div class="so-row">
|
||||
<div>文件名前缀<div class="sub">可选,如频道名</div></div>
|
||||
<input class="so-input text" type="text" placeholder="无前缀" [ngModel]="customNamePrefix" (ngModelChange)="prefixChange.emit($event)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="so-group">
|
||||
<div class="so-label">批量操作</div>
|
||||
<button class="so-btn" (click)="import.emit()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
导入 URL 列表
|
||||
</button>
|
||||
<button class="so-btn" (click)="export.emit()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
导出全部 URL
|
||||
</button>
|
||||
<button class="so-btn" (click)="copy.emit()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
复制全部 URL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="so-group">
|
||||
<div class="so-label">Cookies</div>
|
||||
<p class="so-hint">某些站点需要登录 Cookie 才能下载</p>
|
||||
<input class="so-input wide" type="text" placeholder="域名或 URL,如 bilibili.com" [(ngModel)]="cookieDomain" aria-label="Cookie 域名">
|
||||
<textarea class="so-textarea" rows="4" placeholder="粘贴 Cookie 字符串(key1=value1; key2=value2)" [(ngModel)]="cookieText" aria-label="Cookie 内容"></textarea>
|
||||
<button class="so-btn primary" (click)="saveCookie()" [disabled]="!cookieText.trim() || !cookieDomain.trim()">保存 Cookie</button>
|
||||
</div>
|
||||
|
||||
<div class="so-group" *ngIf="metubeVersion || ytDlpVersion || ytDlpOptionsUpdateTime">
|
||||
<div class="so-label">诊断</div>
|
||||
<div class="so-row flat"><div>MeTube</div><span class="num">{{ metubeVersion || '—' }}</span></div>
|
||||
<div class="so-row flat"><div>yt-dlp</div><span class="num">{{ ytDlpVersion || '—' }}</span></div>
|
||||
<div class="so-row flat" *ngIf="ytDlpOptionsUpdateTime"><div>yt-dlp-options</div><span class="num">{{ ytDlpOptionsUpdateTime }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
:host
|
||||
display: block
|
||||
|
||||
.scrim
|
||||
position: fixed
|
||||
inset: 0
|
||||
background: oklch(25% 0.02 240 / 0.35)
|
||||
opacity: 0
|
||||
pointer-events: none
|
||||
transition: opacity 0.18s ease
|
||||
z-index: 40
|
||||
|
||||
&.show
|
||||
opacity: 1
|
||||
pointer-events: auto
|
||||
|
||||
.slideover
|
||||
position: fixed
|
||||
top: 0
|
||||
right: 0
|
||||
bottom: 0
|
||||
width: 340px
|
||||
max-width: 100vw
|
||||
background: var(--surface)
|
||||
border-left: 1px solid var(--border)
|
||||
box-shadow: -12px 0 40px oklch(30% 0.02 240 / 0.15)
|
||||
transform: translateX(100%)
|
||||
transition: transform 0.22s ease
|
||||
z-index: 50
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
&.show
|
||||
transform: translateX(0)
|
||||
|
||||
.so-head
|
||||
display: flex
|
||||
align-items: center
|
||||
padding: 16px 18px
|
||||
border-bottom: 1px solid var(--border)
|
||||
flex: none
|
||||
|
||||
.so-title
|
||||
font-size: 15px
|
||||
font-weight: 600
|
||||
letter-spacing: -0.01em
|
||||
color: var(--fg)
|
||||
|
||||
.so-body
|
||||
padding: 18px
|
||||
overflow-y: auto
|
||||
flex: 1
|
||||
|
||||
.so-group
|
||||
margin-bottom: 24px
|
||||
|
||||
.so-label
|
||||
font-size: 11px
|
||||
font-weight: 600
|
||||
letter-spacing: 0.06em
|
||||
color: var(--muted)
|
||||
margin-bottom: 10px
|
||||
|
||||
.so-hint
|
||||
font-size: 12px
|
||||
color: var(--muted)
|
||||
margin-bottom: 10px
|
||||
|
||||
.so-row
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: space-between
|
||||
gap: 12px
|
||||
padding: 9px 0
|
||||
font-size: 13.5px
|
||||
color: var(--fg)
|
||||
|
||||
.sub
|
||||
font-size: 11.5px
|
||||
color: var(--muted)
|
||||
margin-top: 2px
|
||||
|
||||
&.flat
|
||||
padding: 6px 0
|
||||
font-size: 12.5px
|
||||
|
||||
.num
|
||||
font-size: 12px
|
||||
color: var(--fg)
|
||||
|
||||
.switch
|
||||
width: 36px
|
||||
height: 21px
|
||||
border-radius: 20px
|
||||
border: none
|
||||
background: oklch(85% 0.01 240)
|
||||
position: relative
|
||||
transition: background 0.15s ease
|
||||
flex: none
|
||||
|
||||
&::after
|
||||
content: ""
|
||||
position: absolute
|
||||
top: 2.5px
|
||||
left: 3px
|
||||
width: 16px
|
||||
height: 16px
|
||||
border-radius: 50%
|
||||
background: var(--surface)
|
||||
box-shadow: 0 1px 3px oklch(20% 0.02 240 / 0.3)
|
||||
transition: transform 0.15s ease
|
||||
|
||||
&.on
|
||||
background: var(--accent)
|
||||
|
||||
&::after
|
||||
transform: translateX(14px)
|
||||
|
||||
.so-input
|
||||
width: 72px
|
||||
height: 30px
|
||||
border: 1px solid var(--border)
|
||||
border-radius: 6px
|
||||
padding: 0 8px
|
||||
font-size: 13px
|
||||
font-family: var(--font-mono)
|
||||
color: var(--fg)
|
||||
text-align: right
|
||||
background: var(--surface)
|
||||
|
||||
&.text
|
||||
width: 140px
|
||||
text-align: left
|
||||
font-family: var(--font-body)
|
||||
|
||||
&.wide
|
||||
width: 100%
|
||||
text-align: left
|
||||
font-family: var(--font-body)
|
||||
margin-bottom: 8px
|
||||
|
||||
.so-textarea
|
||||
width: 100%
|
||||
border: 1px solid var(--border)
|
||||
border-radius: 6px
|
||||
padding: 8px 10px
|
||||
font-size: 12px
|
||||
font-family: var(--font-mono)
|
||||
color: var(--fg)
|
||||
background: var(--surface)
|
||||
resize: vertical
|
||||
margin-bottom: 8px
|
||||
|
||||
.so-btn
|
||||
width: 100%
|
||||
padding: 8px 12px
|
||||
border: 1px solid var(--border)
|
||||
background: var(--surface)
|
||||
border-radius: 6px
|
||||
font-size: 13px
|
||||
color: var(--fg)
|
||||
text-align: left
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 8px
|
||||
margin-bottom: 6px
|
||||
transition: background 0.12s ease
|
||||
|
||||
&:hover:not(:disabled)
|
||||
background: var(--bg)
|
||||
|
||||
&:disabled
|
||||
opacity: 0.5
|
||||
cursor: default
|
||||
|
||||
svg
|
||||
color: var(--muted)
|
||||
flex: none
|
||||
|
||||
&.primary
|
||||
background: var(--accent-tint)
|
||||
border-color: oklch(85% 0.05 145)
|
||||
color: var(--accent-strong)
|
||||
font-weight: 550
|
||||
|
||||
&:hover:not(:disabled)
|
||||
background: var(--accent-tint)
|
||||
|
||||
.icon-btn
|
||||
width: 32px
|
||||
height: 32px
|
||||
display: grid
|
||||
place-items: center
|
||||
border: 1px solid transparent
|
||||
background: none
|
||||
border-radius: 6px
|
||||
color: var(--muted)
|
||||
transition: background 0.12s ease, color 0.12s ease
|
||||
|
||||
&.neutral:hover
|
||||
background: var(--bg)
|
||||
color: var(--fg)
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-advanced-options',
|
||||
standalone: false,
|
||||
templateUrl: './advanced-options.component.html',
|
||||
styleUrls: ['./advanced-options.component.sass']
|
||||
})
|
||||
export class AdvancedOptionsComponent {
|
||||
@Input() open = false;
|
||||
@Input() autoStart = true;
|
||||
@Input() playlistStrictMode = false;
|
||||
@Input() playlistItemLimit: number | null = null;
|
||||
@Input() customNamePrefix = '';
|
||||
@Input() metubeVersion: string | null = null;
|
||||
@Input() ytDlpVersion: string | null = null;
|
||||
@Input() ytDlpOptionsUpdateTime: string | null = null;
|
||||
|
||||
@Output() close = new EventEmitter<void>();
|
||||
@Output() autoStartChange = new EventEmitter<boolean>();
|
||||
@Output() strictChange = new EventEmitter<boolean>();
|
||||
@Output() limitChange = new EventEmitter<number>();
|
||||
@Output() prefixChange = new EventEmitter<string>();
|
||||
@Output() import = new EventEmitter<void>();
|
||||
@Output() export = new EventEmitter<void>();
|
||||
@Output() copy = new EventEmitter<void>();
|
||||
@Output() cookie = new EventEmitter<{ domain: string; cookie: string }>();
|
||||
|
||||
cookieDomain = '';
|
||||
cookieText = '';
|
||||
|
||||
saveCookie(): void {
|
||||
const domain = this.cookieDomain.trim();
|
||||
const cookie = this.cookieText.trim();
|
||||
if (!cookie || !domain) return;
|
||||
this.cookie.emit({ domain, cookie });
|
||||
this.cookieText = '';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,595 +1,149 @@
|
|||
<nav class="navbar navbar-expand-md navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand d-flex align-items-center" href="#">
|
||||
<img src="assets/icons/android-chrome-192x192.png" alt="MeTube Logo" height="32" class="me-2">
|
||||
MeTube
|
||||
</a>
|
||||
<div class="download-metrics">
|
||||
<div class="metric" *ngIf="activeDownloads > 0">
|
||||
<fa-icon [icon]="faDownload" class="text-primary"></fa-icon>
|
||||
<span>{{activeDownloads}} downloading</span>
|
||||
</div>
|
||||
<div class="metric" *ngIf="queuedDownloads > 0">
|
||||
<fa-icon [icon]="faClock" class="text-warning"></fa-icon>
|
||||
<span>{{queuedDownloads}} queued</span>
|
||||
</div>
|
||||
<div class="metric" *ngIf="completedDownloads > 0">
|
||||
<fa-icon [icon]="faCheck" class="text-success"></fa-icon>
|
||||
<span>{{completedDownloads}} completed</span>
|
||||
</div>
|
||||
<div class="metric" *ngIf="failedDownloads > 0">
|
||||
<fa-icon [icon]="faTimesCircle" class="text-danger"></fa-icon>
|
||||
<span>{{failedDownloads}} failed</span>
|
||||
</div>
|
||||
<div class="metric" *ngIf="(totalSpeed | speed) !== ''">
|
||||
<fa-icon [icon]="faTachometerAlt" class="text-info"></fa-icon>
|
||||
<span>{{totalSpeed | speed }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!--
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarsDefault" aria-controls="navbarsDefault" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarsDefault">
|
||||
<ul class="navbar-nav mr-auto">
|
||||
<li class="nav-item active">
|
||||
<a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
-->
|
||||
<div class="navbar-nav ms-auto">
|
||||
<div class="nav-item dropdown">
|
||||
<button class="btn btn-link nav-link py-2 px-0 px-sm-2 dropdown-toggle d-flex align-items-center"
|
||||
id="theme-select"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
data-bs-toggle="dropdown"
|
||||
data-bs-display="static">
|
||||
<fa-icon [icon]="activeTheme.icon"></fa-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end position-absolute" aria-labelledby="theme-select">
|
||||
<li *ngFor="let theme of themes">
|
||||
<button type="button" class="dropdown-item d-flex align-items-center" [ngClass]="{'active' : activeTheme == theme}" (click)="themeChanged(theme)">
|
||||
<span class="me-2 opacity-50">
|
||||
<fa-icon [icon]="theme.icon"></fa-icon>
|
||||
</span>
|
||||
{{ theme.displayName }}
|
||||
<span class="ms-auto" [ngClass]="{'d-none' : activeTheme != theme}">
|
||||
<fa-icon [icon]="faCheck"></fa-icon>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="app">
|
||||
<app-sidebar
|
||||
[status]="status"
|
||||
[selectedFolder]="selectedFolder"
|
||||
[statusCounts]="statusCounts"
|
||||
[folders]="folderNavs"
|
||||
[allCount]="allCount"
|
||||
[metubeVersion]="metubeVersion"
|
||||
[themes]="themes"
|
||||
[activeTheme]="activeTheme"
|
||||
[open]="sidebarOpen"
|
||||
(statusChange)="onStatusChange($event)"
|
||||
(folderChange)="onFolderChange($event)"
|
||||
(openAdvanced)="toggleAdvanced(true)"
|
||||
(openActivity)="activityOpen = !activityOpen"
|
||||
(themeChanged)="themeChanged($event)"
|
||||
(close)="sidebarOpen = false">
|
||||
</app-sidebar>
|
||||
|
||||
<main role="main" class="container container-xl">
|
||||
<!-- Events Display Area -->
|
||||
<div *ngIf="events.length > 0" class="events-container mb-3">
|
||||
<div class="events-header">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<h6 class="mb-0">
|
||||
<fa-icon [icon]="faClock" class="me-2"></fa-icon>
|
||||
Recent Events
|
||||
</h6>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
(click)="clearEvents()"
|
||||
title="Clear all events">
|
||||
<fa-icon [icon]="faTrashAlt" class="me-1"></fa-icon>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="events-list">
|
||||
<div *ngFor="let event of events" class="event-item" [ngClass]="'event-' + event.type">
|
||||
<div class="event-icon">
|
||||
<fa-icon [icon]="faTimesCircle"></fa-icon>
|
||||
</div>
|
||||
<div class="event-content">
|
||||
<div class="event-message">{{ event.message }}</div>
|
||||
<div class="event-url" *ngIf="event.url" [title]="event.url">
|
||||
{{ event.url }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-time">
|
||||
{{ getRelativeTime(event.timestamp) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form #f="ngForm">
|
||||
<div class="container add-url-box">
|
||||
<!-- Main URL Input with Download Button -->
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<div class="input-group input-group-lg shadow-sm">
|
||||
<input type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
class="form-control form-control-lg"
|
||||
placeholder="Enter video or playlist URL"
|
||||
name="addUrl"
|
||||
[(ngModel)]="addUrl"
|
||||
[disabled]="addInProgress || downloads.loading">
|
||||
<button class="btn btn-primary btn-lg px-4"
|
||||
type="submit"
|
||||
(click)="addDownload()"
|
||||
[disabled]="addInProgress || downloads.loading">
|
||||
<span class="spinner-border spinner-border-sm" role="status" id="add-spinner" *ngIf="addInProgress"></span>
|
||||
{{ addInProgress ? "Adding..." : "Download" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options Row -->
|
||||
<div class="row mb-3 g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Quality</span>
|
||||
<select class="form-select"
|
||||
name="quality"
|
||||
[(ngModel)]="quality"
|
||||
(change)="qualityChanged()"
|
||||
[disabled]="addInProgress || downloads.loading">
|
||||
<option *ngFor="let q of qualities" [ngValue]="q.id">{{ q.text }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Format</span>
|
||||
<select class="form-select"
|
||||
name="format"
|
||||
[(ngModel)]="format"
|
||||
(change)="formatChanged()"
|
||||
[disabled]="addInProgress || downloads.loading">
|
||||
<option *ngFor="let f of formats" [ngValue]="f.id">{{ f.text }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary w-100 h-100"
|
||||
(click)="toggleAdvanced()">
|
||||
Advanced Options
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Options Panel -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="collapse show" id="advancedOptions" [ngbCollapse]="!isAdvancedOpen">
|
||||
<div class="card card-body">
|
||||
<!-- Advanced Settings -->
|
||||
<div class="row g-3 mb-2">
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Auto Start</span>
|
||||
<select class="form-select"
|
||||
name="autoStart"
|
||||
[(ngModel)]="autoStart"
|
||||
(change)="autoStartChanged()"
|
||||
<main class="main">
|
||||
<app-command-bar
|
||||
[url]="addUrl"
|
||||
(urlChange)="addUrl = $event"
|
||||
[quality]="quality"
|
||||
[format]="format"
|
||||
[folder]="folder"
|
||||
[qualities]="qualities"
|
||||
[formats]="formats"
|
||||
[customDirs$]="customDirs$"
|
||||
[allowCustomDir]="allowCustomDir.bind(this)"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
ngbTooltip="Automatically start downloads when added">
|
||||
<option [ngValue]="true">Yes</option>
|
||||
<option [ngValue]="false">No</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Download Folder</span>
|
||||
<ng-select [items]="customDirs$ | async"
|
||||
placeholder="Default"
|
||||
[addTag]="allowCustomDir.bind(this)"
|
||||
addTagText="Create directory"
|
||||
bindLabel="folder"
|
||||
[(ngModel)]="folder"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
[virtualScroll]="true"
|
||||
[clearable]="true"
|
||||
(add)="addDownload($event)"
|
||||
(qualityChange)="setQuality($event)"
|
||||
(formatChange)="setFormat($event)"
|
||||
(folderChange)="setFolder($event)"
|
||||
(openAdvanced)="toggleAdvanced(true)"
|
||||
(menu)="sidebarOpen = true">
|
||||
</app-command-bar>
|
||||
|
||||
<app-stats-strip
|
||||
[active]="activeDownloads"
|
||||
[speed]="totalSpeed"
|
||||
[queued]="queuedDownloads"
|
||||
[storage]="librarySize">
|
||||
</app-stats-strip>
|
||||
|
||||
<div class="content">
|
||||
<app-active-downloads
|
||||
[items]="downloadingRows"
|
||||
[totalSpeed]="totalSpeed"
|
||||
[loading]="downloads.loading"
|
||||
[searchable]="true"
|
||||
[closeOnSelect]="true"
|
||||
ngbTooltip="Choose where to save downloads. Type to create a new folder.">
|
||||
</ng-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Custom Name Prefix</span>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
placeholder="Default"
|
||||
name="customNamePrefix"
|
||||
[(ngModel)]="customNamePrefix"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
ngbTooltip="Add a prefix to downloaded filenames">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Items Limit</span>
|
||||
<input type="number"
|
||||
min="0"
|
||||
class="form-control"
|
||||
placeholder="Default"
|
||||
name="playlistItemLimit"
|
||||
(keydown)="isNumber($event)"
|
||||
[(ngModel)]="playlistItemLimit"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
ngbTooltip="Maximum number of items to download from a playlist (0 = no limit)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
name="playlistStrictMode"
|
||||
[(ngModel)]="playlistStrictMode"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
ngbTooltip="Only download playlists when URL explicitly points to a playlist">
|
||||
<label class="form-check-label">Strict Playlist Mode</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
(cancel)="cancelDownload($event)">
|
||||
</app-active-downloads>
|
||||
|
||||
<!-- Advanced Actions -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<hr class="my-3">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<button type="button"
|
||||
class="btn btn-secondary w-100"
|
||||
(click)="openBatchImportModal()">
|
||||
<fa-icon [icon]="faFileImport" class="me-2"></fa-icon>
|
||||
Import URLs
|
||||
</button>
|
||||
<app-download-list
|
||||
[status]="status"
|
||||
[rows]="filteredRows"
|
||||
[folderName]="folderName"
|
||||
[filterText]="filter"
|
||||
(filterChange)="filter = $event"
|
||||
[sort]="sort"
|
||||
(sortChange)="sort = $event"
|
||||
[sortAscending]="sortAscending"
|
||||
(sortAscendingChange)="setSortDirection($event)"
|
||||
[view]="view"
|
||||
(viewChange)="setView($event)"
|
||||
[loading]="downloads.loading"
|
||||
[customDirsFor]="customDirsForDownload.bind(this)"
|
||||
[allowCustomDir]="allowCustomDir.bind(this)"
|
||||
[downloadUrlFor]="buildDownloadLink.bind(this)"
|
||||
(start)="startItems($event)"
|
||||
(del)="delItems($event, status === 'completed' ? 'done' : 'queue')"
|
||||
(move)="moveItems($event.ids, $event.folder)"
|
||||
(retry)="retryDownload($event.key, $event.dl)"
|
||||
(folderEdit)="folderChanged($event.key, $event.folder)"
|
||||
(clearCompleted)="clearCompletedDownloads()"
|
||||
(clearFailed)="clearFailedDownloads()"
|
||||
(retryFailed)="retryFailedDownloads()"
|
||||
(downloadSelected)="downloadSelectedFiles($event)">
|
||||
</app-download-list>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button type="button"
|
||||
class="btn btn-secondary w-100"
|
||||
(click)="exportBatchUrls('all')">
|
||||
<fa-icon [icon]="faFileExport" class="me-2"></fa-icon>
|
||||
Export URLs
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button type="button"
|
||||
class="btn btn-secondary w-100"
|
||||
(click)="copyBatchUrls('all')">
|
||||
<fa-icon [icon]="faCopy" class="me-2"></fa-icon>
|
||||
Copy URLs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Batch Import Modal -->
|
||||
<div class="modal fade" tabindex="-1" role="dialog" [ngClass]="{'show': batchImportModalOpen}" [ngStyle]="{'display': batchImportModalOpen ? 'block' : 'none'}">
|
||||
<!-- Advanced options slide-over -->
|
||||
<app-advanced-options
|
||||
[open]="isAdvancedOpen"
|
||||
[autoStart]="autoStart"
|
||||
(autoStartChange)="setAutoStart($event)"
|
||||
[playlistStrictMode]="playlistStrictMode"
|
||||
(strictChange)="playlistStrictMode = $event"
|
||||
[playlistItemLimit]="playlistItemLimit"
|
||||
(limitChange)="playlistItemLimit = $event"
|
||||
[customNamePrefix]="customNamePrefix"
|
||||
(prefixChange)="customNamePrefix = $event"
|
||||
[metubeVersion]="metubeVersion"
|
||||
[ytDlpVersion]="ytDlpVersion"
|
||||
[ytDlpOptionsUpdateTime]="ytDlpOptionsUpdateTime"
|
||||
(close)="toggleAdvanced(false)"
|
||||
(import)="openBatchImportModal()"
|
||||
(export)="exportBatchUrls('all')"
|
||||
(copy)="copyBatchUrls('all')"
|
||||
(cookie)="saveCookie($event.domain, $event.cookie)">
|
||||
</app-advanced-options>
|
||||
|
||||
<!-- Batch import modal -->
|
||||
<div class="modal fade" tabindex="-1" role="dialog" [ngClass]="{'show': batchImportModalOpen}" [ngStyle]="{'display': batchImportModalOpen ? 'block' : 'none'}">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Batch Import URLs</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="closeBatchImportModal()"></button>
|
||||
<h5 class="modal-title">批量导入 URL</h5>
|
||||
<button type="button" class="btn-close" aria-label="关闭" (click)="closeBatchImportModal()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<textarea [(ngModel)]="batchImportText" class="form-control" rows="6"
|
||||
placeholder="Paste one video URL per line"></textarea>
|
||||
<textarea [(ngModel)]="batchImportText" class="form-control" rows="6" placeholder="每行粘贴一个视频 URL"></textarea>
|
||||
<div class="mt-2">
|
||||
<small *ngIf="batchImportStatus">{{ batchImportStatus }}</small>
|
||||
<small *ngIf="batchImportStatus" class="batch-status">{{ batchImportStatus }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-danger me-auto" *ngIf="importInProgress" (click)="cancelBatchImport()">
|
||||
Cancel Import
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" (click)="closeBatchImportModal()">Close</button>
|
||||
<button type="button" class="btn btn-primary" (click)="startBatchImport()" [disabled]="importInProgress">
|
||||
Import URLs
|
||||
<button type="button" class="btn btn-danger me-auto" *ngIf="importInProgress" (click)="cancelBatchImport()">取消导入</button>
|
||||
<button type="button" class="btn btn-secondary" (click)="closeBatchImportModal()">关闭</button>
|
||||
<button type="button" class="btn btn-primary" (click)="startBatchImport()" [disabled]="importInProgress">导入 URL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity drawer -->
|
||||
<div class="scrim" *ngIf="activityOpen" (click)="activityOpen = false"></div>
|
||||
<aside class="activity" [class.show]="activityOpen" aria-label="活动日志">
|
||||
<div class="activity-head">
|
||||
<span class="activity-title">活动日志</span>
|
||||
<button type="button" class="icon-btn neutral" (click)="activityOpen = false" aria-label="关闭">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="activity-body">
|
||||
<div *ngIf="events.length === 0" class="activity-empty">暂无事件</div>
|
||||
<div *ngFor="let event of events" class="activity-item">
|
||||
<div class="activity-msg">{{ event.message }}</div>
|
||||
<div class="activity-url" *ngIf="event.url" [title]="event.url">{{ event.url }}</div>
|
||||
<div class="activity-time num">{{ getRelativeTime(event.timestamp) }}</div>
|
||||
</div>
|
||||
<button type="button" class="ghost-btn activity-clear" (click)="clearEvents()">清空日志</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
<div *ngIf="downloads.loading" class="alert alert-info" role="alert">
|
||||
Connecting to server...
|
||||
</div>
|
||||
<ul class="nav nav-tabs metube-tabs">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" role="button" [class.active]="activeTab === 'queue'" (click)="setActiveTab('queue')">
|
||||
Downloading <span class="badge text-bg-secondary">{{ tabDownloads('queue').size }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" role="button" [class.active]="activeTab === 'pending'" (click)="setActiveTab('pending')">
|
||||
Pending <span class="badge text-bg-secondary">{{ tabDownloads('pending').size }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" role="button" [class.active]="activeTab === 'done'" (click)="setActiveTab('done')">
|
||||
Completed <span class="badge text-bg-secondary">{{ downloads.done.size }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div [class.d-none]="activeTab !== 'queue'">
|
||||
<div class="px-2 py-3 border-bottom d-flex align-items-center flex-wrap">
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt"></fa-icon> Cancel selected</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDownloadSelected (click)="startSelectedDownloads('queue')"><fa-icon [icon]="faDownload"></fa-icon> Download selected</button>
|
||||
<div class="form-check form-switch d-flex align-items-center mb-0 ps-0">
|
||||
<input class="form-check-input ms-0 me-2" type="checkbox" role="switch" id="queueGroupByFolder" [(ngModel)]="groupByFolder" (change)="groupByFolderChanged()" ngbTooltip="Group downloads by folder">
|
||||
<label class="form-check-label mb-0" for="queueGroupByFolder"><fa-icon [icon]="faFolderOpen"></fa-icon> Group by folder</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 1rem;">
|
||||
<app-master-checkbox #queueMasterCheckbox [id]="'queue'" [list]="tabDownloads('queue')" (changed)="queueSelectionChanged($event)"></app-master-checkbox>
|
||||
</th>
|
||||
<th scope="col">Video</th>
|
||||
<th scope="col" class="sortable-header" (click)="toggleSortOrder()" ngbTooltip="Sort by creation time">
|
||||
Created
|
||||
<fa-icon [icon]="sortAscending ? faSortUp : faSortDown"></fa-icon>
|
||||
</th>
|
||||
<th scope="col">Folder</th>
|
||||
<th scope="col" style="width: 8rem;">Speed</th>
|
||||
<th scope="col" style="width: 7rem;">ETA</th>
|
||||
<th scope="col" style="width: 6rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<ng-container *ngFor="let row of displayRows('queue'); trackBy: identifyDisplayRow">
|
||||
<tr *ngIf="row.type === 'header'" class="folder-group-header" role="button" (click)="toggleFolderCollapse(row.rawFolder)">
|
||||
<td colspan="7">
|
||||
<fa-icon [icon]="row.collapsed ? faChevronRight : faChevronDown" class="folder-group-chevron"></fa-icon>
|
||||
<fa-icon [icon]="faFolderOpen"></fa-icon> {{ row.folder }}
|
||||
<span class="folder-group-count">{{ row.count }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="row.type === 'item'" [class.disabled]='row.value.deleting'>
|
||||
<td>
|
||||
<app-slave-checkbox [id]="row.key" [master]="queueMasterCheckbox" [checkable]="row.value"></app-slave-checkbox>
|
||||
</td>
|
||||
<td title="{{ row.value.filename }}">
|
||||
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
|
||||
<div>
|
||||
<span class="website-tag" *ngIf="row.value.website" [title]="row.value.website">{{ getSimplifiedDomain(row.value.website) }}</span>
|
||||
{{ row.value.title }}
|
||||
</div>
|
||||
<ngb-progressbar height="1.5rem" [showValue]="row.value.status != 'preparing'" [striped]="row.value.status == 'preparing'" [animated]="row.value.status == 'preparing'" type="success" [value]="row.value.status == 'preparing' ? 100 : row.value.percent | number:'1.0-0'" class="download-progressbar"></ngb-progressbar>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatTimestamp(row.value.timestamp) }}</td>
|
||||
<td>{{ row.value.folder || 'Default' }}</td>
|
||||
<td>{{ row.value.speed | speed }}</td>
|
||||
<td>{{ row.value.eta | eta }}</td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
<button *ngIf="row.value.status === 'pending'" type="button" class="btn btn-link" (click)="downloadItemByKey(row.key)"><fa-icon [icon]="faDownload"></fa-icon></button>
|
||||
<button type="button" class="btn btn-link" (click)="delDownload('queue', row.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
|
||||
<a href="{{row.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-container>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div [class.d-none]="activeTab !== 'pending'">
|
||||
<div class="px-2 py-3 border-bottom d-flex align-items-center flex-wrap">
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #pendingDelSelected (click)="delSelectedDownloads('pending')"><fa-icon [icon]="faTrashAlt"></fa-icon> Cancel selected</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #pendingDownloadSelected (click)="startSelectedDownloads('pending')"><fa-icon [icon]="faDownload"></fa-icon> Download selected</button>
|
||||
<div class="form-check form-switch d-flex align-items-center mb-0 ps-0">
|
||||
<input class="form-check-input ms-0 me-2" type="checkbox" role="switch" id="pendingGroupByFolder" [(ngModel)]="groupByFolder" (change)="groupByFolderChanged()" ngbTooltip="Group downloads by folder">
|
||||
<label class="form-check-label mb-0" for="pendingGroupByFolder"><fa-icon [icon]="faFolderOpen"></fa-icon> Group by folder</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 1rem;">
|
||||
<app-master-checkbox #pendingMasterCheckbox [id]="'pending'" [list]="tabDownloads('pending')" (changed)="pendingSelectionChanged($event)"></app-master-checkbox>
|
||||
</th>
|
||||
<th scope="col">Video</th>
|
||||
<th scope="col" class="sortable-header" (click)="toggleSortOrder()" ngbTooltip="Sort by creation time">
|
||||
Created
|
||||
<fa-icon [icon]="sortAscending ? faSortUp : faSortDown"></fa-icon>
|
||||
</th>
|
||||
<th scope="col">Folder</th>
|
||||
<th scope="col" style="width: 8rem;">Speed</th>
|
||||
<th scope="col" style="width: 7rem;">ETA</th>
|
||||
<th scope="col" style="width: 6rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<ng-container *ngFor="let row of displayRows('pending'); trackBy: identifyDisplayRow">
|
||||
<tr *ngIf="row.type === 'header'" class="folder-group-header" role="button" (click)="toggleFolderCollapse(row.rawFolder)">
|
||||
<td colspan="7">
|
||||
<fa-icon [icon]="row.collapsed ? faChevronRight : faChevronDown" class="folder-group-chevron"></fa-icon>
|
||||
<fa-icon [icon]="faFolderOpen"></fa-icon> {{ row.folder }}
|
||||
<span class="folder-group-count">{{ row.count }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="row.type === 'item'" [class.disabled]='row.value.deleting'>
|
||||
<td>
|
||||
<app-slave-checkbox [id]="row.key" [master]="pendingMasterCheckbox" [checkable]="row.value"></app-slave-checkbox>
|
||||
</td>
|
||||
<td title="{{ row.value.filename }}">
|
||||
<span class="website-tag" *ngIf="row.value.website" [title]="row.value.website">{{ getSimplifiedDomain(row.value.website) }}</span>
|
||||
{{ row.value.title }}
|
||||
</td>
|
||||
<td>{{ formatTimestamp(row.value.timestamp) }}</td>
|
||||
<td class="folder-edit-cell">
|
||||
<ng-select [items]="customDirsForDownload(row.value)"
|
||||
placeholder="Default"
|
||||
[addTag]="allowCustomDir.bind(this)"
|
||||
addTagText="Create directory"
|
||||
[ngModel]="row.value.folder"
|
||||
(change)="folderChanged(row.key, $event)"
|
||||
[clearable]="true"
|
||||
[searchable]="true"
|
||||
[closeOnSelect]="true"
|
||||
appendTo="body"
|
||||
ngbTooltip="Change download folder">
|
||||
</ng-select>
|
||||
</td>
|
||||
<td>{{ row.value.speed | speed }}</td>
|
||||
<td>{{ row.value.eta | eta }}</td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
<button *ngIf="row.value.status === 'pending'" type="button" class="btn btn-link" (click)="downloadItemByKey(row.key)"><fa-icon [icon]="faDownload"></fa-icon></button>
|
||||
<button type="button" class="btn btn-link" (click)="delDownload('queue', row.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
|
||||
<a href="{{row.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-container>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div [class.d-none]="activeTab !== 'done'">
|
||||
<div class="px-2 py-3 border-bottom d-flex align-items-center flex-wrap">
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt"></fa-icon> Clear selected</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearCompleted (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle"></fa-icon> Clear completed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearFailed (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle"></fa-icon> Clear failed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneRetryFailed (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt"></fa-icon> Retry failed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDownloadSelected (click)="downloadSelectedFiles()"><fa-icon [icon]="faDownload"></fa-icon> Download Selected</button>
|
||||
<div class="form-check form-switch d-flex align-items-center mb-0 ps-0">
|
||||
<input class="form-check-input ms-0 me-2" type="checkbox" role="switch" id="doneGroupByFolder" [(ngModel)]="groupByFolder" (change)="groupByFolderChanged()" ngbTooltip="Group downloads by folder">
|
||||
<label class="form-check-label mb-0" for="doneGroupByFolder"><fa-icon [icon]="faFolderOpen"></fa-icon> Group by folder</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 1rem;">
|
||||
<app-master-checkbox #doneMasterCheckbox [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)"></app-master-checkbox>
|
||||
</th>
|
||||
<th scope="col">Video</th>
|
||||
<th scope="col" class="sortable-header" (click)="toggleSortOrder()" ngbTooltip="Sort by creation time">
|
||||
Created
|
||||
<fa-icon [icon]="sortAscending ? faSortUp : faSortDown"></fa-icon>
|
||||
</th>
|
||||
<th scope="col">Folder</th>
|
||||
<th scope="col">File Size</th>
|
||||
<th scope="col" style="width: 8rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<ng-container *ngFor="let row of displayRows('done'); trackBy: identifyDisplayRow">
|
||||
<tr *ngIf="row.type === 'header'" class="folder-group-header" role="button" (click)="toggleFolderCollapse(row.rawFolder)">
|
||||
<td colspan="6">
|
||||
<fa-icon [icon]="row.collapsed ? faChevronRight : faChevronDown" class="folder-group-chevron"></fa-icon>
|
||||
<fa-icon [icon]="faFolderOpen"></fa-icon> {{ row.folder }}
|
||||
<span class="folder-group-count">{{ row.count }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="row.type === 'item'" [class.disabled]='row.value.deleting'>
|
||||
<td>
|
||||
<app-slave-checkbox [id]="row.key" [master]="doneMasterCheckbox" [checkable]="row.value"></app-slave-checkbox>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: inline-block; width: 1.5rem;">
|
||||
<fa-icon *ngIf="row.value.status == 'finished'" [icon]="faCheckCircle" class="text-success"></fa-icon>
|
||||
<fa-icon *ngIf="row.value.status == 'error'" [icon]="faTimesCircle" class="text-danger"></fa-icon>
|
||||
</div>
|
||||
<span class="website-tag" *ngIf="row.value.website" [title]="row.value.website">{{ getSimplifiedDomain(row.value.website) }}</span>
|
||||
<span class="file-exists-indicator" *ngIf="row.value.filename" [ngClass]="{'file-exists': row.value.file_exists, 'file-missing': row.value.file_exists === false}" [title]="row.value.file_exists ? 'File exists' : 'File not found'">
|
||||
<fa-icon *ngIf="row.value.file_exists" [icon]="faCheckCircle" class="text-success"></fa-icon>
|
||||
<fa-icon *ngIf="row.value.file_exists === false" [icon]="faTimesCircle" class="text-warning"></fa-icon>
|
||||
</span>
|
||||
<span ngbTooltip="{{row.value.msg}} | {{row.value.error}}">
|
||||
<a *ngIf="!!row.value.filename && row.value.file_exists !== false; else noDownloadLink" href="{{buildDownloadLink(row.value)}}" target="_blank">{{ row.value.title }}</a>
|
||||
</span>
|
||||
<ng-template #noDownloadLink>
|
||||
{{row.value.title}}
|
||||
<span *ngIf="row.value.msg"><br>{{row.value.msg}}</span>
|
||||
<span *ngIf="row.value.error"><br>Error: {{row.value.error}}</span>
|
||||
</ng-template>
|
||||
</td>
|
||||
<td>{{ formatTimestamp(row.value.timestamp) }}</td>
|
||||
<td class="folder-edit-cell">
|
||||
<ng-select [items]="customDirsForDownload(row.value)"
|
||||
placeholder="Default"
|
||||
[addTag]="allowCustomDir.bind(this)"
|
||||
addTagText="Create directory"
|
||||
[ngModel]="row.value.folder"
|
||||
(change)="folderChanged(row.key, $event)"
|
||||
[clearable]="true"
|
||||
[searchable]="true"
|
||||
[closeOnSelect]="true"
|
||||
appendTo="body"
|
||||
ngbTooltip="Move file to another folder">
|
||||
</ng-select>
|
||||
</td>
|
||||
<td>
|
||||
<span *ngIf="row.value.size">{{ row.value.size | fileSize }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
<button *ngIf="row.value.status == 'error'" type="button" class="btn btn-link" (click)="retryDownload(row.key, row.value)"><fa-icon [icon]="faRedoAlt"></fa-icon></button>
|
||||
<a *ngIf="row.value.filename" href="{{buildDownloadLink(row.value)}}" download class="btn btn-link"><fa-icon [icon]="faDownload"></fa-icon></a>
|
||||
<a href="{{row.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
|
||||
<button type="button" class="btn btn-link" (click)="delDownload('done', row.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-container>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main><!-- /.container -->
|
||||
|
||||
<footer class="footer navbar-dark bg-dark py-3 mt-5">
|
||||
<div class="container text-center">
|
||||
<div class="footer-content" *ngIf="ytDlpVersion && metubeVersion">
|
||||
<div class="version-item">
|
||||
<span class="version-label">yt-dlp</span>
|
||||
<span class="version-value">{{ytDlpVersion}}</span>
|
||||
</div>
|
||||
<div class="version-separator"></div>
|
||||
<div class="version-item">
|
||||
<span class="version-label">MeTube</span>
|
||||
<span class="version-value">{{metubeVersion}}</span>
|
||||
</div>
|
||||
<div class="version-separator"></div>
|
||||
<div class="version-item" *ngIf="ytDlpOptionsUpdateTime">
|
||||
<span class="version-label">yt-dlp-options</span>
|
||||
<span class="version-value">{{ytDlpOptionsUpdateTime}}</span>
|
||||
</div>
|
||||
<div class="version-separator" *ngIf="ytDlpOptionsUpdateTime"></div>
|
||||
<a href="https://github.com/alexta69/metube" target="_blank" class="github-link">
|
||||
<fa-icon [icon]="faGithub"></fa-icon>
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<app-toasts></app-toasts>
|
||||
|
|
|
|||
|
|
@ -1,341 +1,149 @@
|
|||
.button-toggle-theme:focus, .button-toggle-theme:active
|
||||
box-shadow: none
|
||||
outline: 0px
|
||||
:host
|
||||
display: block
|
||||
height: 100vh
|
||||
|
||||
.add-url-box
|
||||
max-width: 960px
|
||||
margin: 4rem auto
|
||||
.app
|
||||
display: grid
|
||||
grid-template-columns: var(--sidebar-w) 1fr
|
||||
height: 100vh
|
||||
overflow: hidden
|
||||
|
||||
.add-url-component
|
||||
margin: 0.5rem auto
|
||||
|
||||
.add-url-group
|
||||
width: 100%
|
||||
|
||||
button.add-url
|
||||
width: 100%
|
||||
|
||||
.folder-dropdown-menu
|
||||
width: 500px
|
||||
max-width: calc(100vw - 3rem)
|
||||
|
||||
.folder-dropdown-menu .input-group
|
||||
.main
|
||||
display: flex
|
||||
padding-left: 5px
|
||||
padding-right: 5px
|
||||
flex-direction: column
|
||||
overflow: hidden
|
||||
position: relative
|
||||
min-width: 0
|
||||
|
||||
.sortable-header
|
||||
cursor: pointer
|
||||
user-select: none
|
||||
.content
|
||||
flex: 1
|
||||
min-height: 0
|
||||
overflow: hidden
|
||||
padding: 18px 22px 20px
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 22px
|
||||
|
||||
.metube-tabs
|
||||
margin-top: 3.5rem
|
||||
font-size: 1.1rem
|
||||
// Activity drawer
|
||||
.scrim
|
||||
position: fixed
|
||||
inset: 0
|
||||
background: oklch(25% 0.02 240 / 0.35)
|
||||
z-index: 40
|
||||
|
||||
.nav-link
|
||||
cursor: pointer
|
||||
.activity
|
||||
position: fixed
|
||||
top: 0
|
||||
right: 0
|
||||
bottom: 0
|
||||
width: 340px
|
||||
max-width: 100vw
|
||||
background: var(--surface)
|
||||
border-left: 1px solid var(--border)
|
||||
box-shadow: -12px 0 40px oklch(30% 0.02 240 / 0.15)
|
||||
transform: translateX(100%)
|
||||
transition: transform 0.22s ease
|
||||
z-index: 50
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
button:hover
|
||||
text-decoration: none
|
||||
&.show
|
||||
transform: translateX(0)
|
||||
|
||||
th
|
||||
border-top: 0
|
||||
border-bottom-width: 3px !important
|
||||
vertical-align: middle !important
|
||||
white-space: nowrap
|
||||
.activity-head
|
||||
display: flex
|
||||
align-items: center
|
||||
padding: 16px 18px
|
||||
border-bottom: 1px solid var(--border)
|
||||
flex: none
|
||||
|
||||
td
|
||||
vertical-align: middle
|
||||
|
||||
.disabled
|
||||
opacity: 0.5
|
||||
pointer-events: none
|
||||
|
||||
.folder-edit-cell
|
||||
min-width: 12rem
|
||||
|
||||
.folder-group-header
|
||||
cursor: pointer
|
||||
user-select: none
|
||||
|
||||
td
|
||||
background: var(--bs-secondary-bg)
|
||||
.activity-title
|
||||
font-size: 15px
|
||||
font-weight: 600
|
||||
color: var(--bs-secondary-color)
|
||||
padding-top: 0.4rem
|
||||
padding-bottom: 0.4rem
|
||||
color: var(--fg)
|
||||
|
||||
.folder-group-chevron
|
||||
width: 0.75rem
|
||||
opacity: 0.7
|
||||
|
||||
.folder-group-count
|
||||
display: inline-block
|
||||
margin-left: 0.5rem
|
||||
padding: 0.1rem 0.5rem
|
||||
border-radius: 1rem
|
||||
background: var(--bs-tertiary-bg)
|
||||
font-size: 0.75rem
|
||||
font-weight: 500
|
||||
|
||||
.form-switch
|
||||
input
|
||||
margin-top: 5px
|
||||
|
||||
.download-progressbar
|
||||
width: 12rem
|
||||
.icon-btn
|
||||
margin-left: auto
|
||||
|
||||
.batch-panel
|
||||
margin-top: 15px
|
||||
border: 1px solid #ccc
|
||||
border-radius: 8px
|
||||
padding: 15px
|
||||
background-color: #fff
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1)
|
||||
.activity-body
|
||||
flex: 1
|
||||
overflow-y: auto
|
||||
padding: 14px 18px
|
||||
|
||||
.batch-panel-header
|
||||
border-bottom: 1px solid #eee
|
||||
padding-bottom: 8px
|
||||
margin-bottom: 15px
|
||||
h4
|
||||
font-size: 1.5rem
|
||||
margin: 0
|
||||
.activity-empty
|
||||
text-align: center
|
||||
color: var(--muted)
|
||||
font-size: 13px
|
||||
padding: 40px 0
|
||||
|
||||
.batch-panel-body
|
||||
textarea.form-control
|
||||
resize: vertical
|
||||
.activity-item
|
||||
padding: 10px 0
|
||||
border-bottom: 1px solid var(--border)
|
||||
|
||||
.activity-msg
|
||||
font-size: 13px
|
||||
color: var(--fg)
|
||||
margin-bottom: 3px
|
||||
|
||||
.activity-url
|
||||
font-size: 11.5px
|
||||
color: var(--muted)
|
||||
white-space: nowrap
|
||||
overflow: hidden
|
||||
text-overflow: ellipsis
|
||||
font-family: var(--font-mono)
|
||||
|
||||
.activity-time
|
||||
font-size: 11px
|
||||
color: var(--muted)
|
||||
margin-top: 4px
|
||||
|
||||
.activity-clear
|
||||
margin-top: 14px
|
||||
width: 100%
|
||||
justify-content: center
|
||||
|
||||
.icon-btn
|
||||
width: 32px
|
||||
height: 32px
|
||||
display: grid
|
||||
place-items: center
|
||||
border: 1px solid transparent
|
||||
background: none
|
||||
border-radius: 6px
|
||||
color: var(--muted)
|
||||
transition: background 0.12s ease, color 0.12s ease
|
||||
|
||||
&.neutral:hover
|
||||
background: var(--bg)
|
||||
color: var(--fg)
|
||||
|
||||
.ghost-btn
|
||||
display: inline-flex
|
||||
align-items: center
|
||||
gap: 7px
|
||||
padding: 7px 10px
|
||||
border: 1px solid var(--border)
|
||||
background: var(--surface)
|
||||
border-radius: 6px
|
||||
font-size: 12.5px
|
||||
color: var(--fg)
|
||||
transition: background 0.12s ease, border-color 0.12s ease
|
||||
|
||||
&:hover
|
||||
background: var(--bg)
|
||||
border-color: oklch(82% 0.01 240)
|
||||
|
||||
.batch-status
|
||||
font-size: 0.9rem
|
||||
color: #555
|
||||
|
||||
.d-flex.my-3
|
||||
margin-top: 1rem
|
||||
margin-bottom: 1rem
|
||||
color: var(--muted)
|
||||
|
||||
.modal.fade.show
|
||||
background-color: rgba(0, 0, 0, 0.5)
|
||||
|
||||
.modal-header
|
||||
border-bottom: 1px solid #eee
|
||||
@media (max-width: 900px)
|
||||
.app
|
||||
grid-template-columns: 1fr
|
||||
|
||||
.modal-body
|
||||
textarea.form-control
|
||||
resize: vertical
|
||||
|
||||
.add-url
|
||||
display: inline-flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
|
||||
.spinner-border
|
||||
margin-right: 0.5rem
|
||||
|
||||
::ng-deep .ng-select
|
||||
flex: 1
|
||||
.ng-select-container
|
||||
min-height: 38px
|
||||
.ng-value
|
||||
white-space: nowrap
|
||||
overflow: visible
|
||||
.ng-dropdown-panel
|
||||
.ng-dropdown-panel-items
|
||||
max-height: 300px
|
||||
.ng-option
|
||||
white-space: nowrap
|
||||
overflow: visible
|
||||
text-overflow: ellipsis
|
||||
|
||||
:host
|
||||
display: flex
|
||||
flex-direction: column
|
||||
min-height: 100vh
|
||||
|
||||
main
|
||||
flex-grow: 1
|
||||
|
||||
.footer
|
||||
width: 100%
|
||||
padding: 10px 0
|
||||
background: linear-gradient(to bottom, rgba(0,0,0,0.2), rgba(0,0,0,0.1))
|
||||
|
||||
.footer-content
|
||||
display: flex
|
||||
justify-content: center
|
||||
align-items: center
|
||||
gap: 20px
|
||||
color: #fff
|
||||
font-size: 0.9rem
|
||||
|
||||
.version-item
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 8px
|
||||
|
||||
.version-label
|
||||
font-size: 0.75rem
|
||||
text-transform: uppercase
|
||||
letter-spacing: 0.5px
|
||||
opacity: 0.7
|
||||
|
||||
.version-value
|
||||
font-family: monospace
|
||||
font-size: 0.85rem
|
||||
padding: 2px 6px
|
||||
background: rgba(255,255,255,0.1)
|
||||
border-radius: 4px
|
||||
|
||||
.version-separator
|
||||
width: 1px
|
||||
height: 16px
|
||||
background: rgba(255,255,255,0.2)
|
||||
margin: 0 4px
|
||||
|
||||
.github-link
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 6px
|
||||
color: #fff
|
||||
text-decoration: none
|
||||
font-size: 0.85rem
|
||||
padding: 2px 8px
|
||||
border-radius: 4px
|
||||
transition: background-color 0.2s ease
|
||||
|
||||
&:hover
|
||||
background: rgba(255,255,255,0.1)
|
||||
color: #fff
|
||||
text-decoration: none
|
||||
|
||||
i
|
||||
font-size: 1rem
|
||||
|
||||
.download-metrics
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 16px
|
||||
margin-left: 24px
|
||||
|
||||
.metric
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 6px
|
||||
font-size: 0.9rem
|
||||
color: #adb5bd
|
||||
|
||||
fa-icon
|
||||
font-size: 1rem
|
||||
|
||||
span
|
||||
white-space: nowrap
|
||||
|
||||
.website-tag
|
||||
display: inline-block
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%)
|
||||
color: white
|
||||
padding: 2px 8px
|
||||
border-radius: 4px
|
||||
font-size: 0.7rem
|
||||
font-weight: 600
|
||||
text-transform: uppercase
|
||||
letter-spacing: 0.5px
|
||||
margin-right: 8px
|
||||
white-space: nowrap
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)
|
||||
transition: all 0.2s ease
|
||||
|
||||
&:hover
|
||||
transform: translateY(-1px)
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15)
|
||||
|
||||
.file-exists-indicator
|
||||
display: inline-block
|
||||
margin-right: 6px
|
||||
font-size: 0.9rem
|
||||
|
||||
fa-icon
|
||||
vertical-align: middle
|
||||
|
||||
// Events display area
|
||||
.events-container
|
||||
background: var(--bs-body-bg)
|
||||
border: 1px solid var(--bs-border-color)
|
||||
border-radius: 0.5rem
|
||||
overflow: hidden
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1)
|
||||
margin-top: 1rem
|
||||
|
||||
.events-header
|
||||
padding: 0.75rem 1rem
|
||||
background: var(--bs-secondary-bg)
|
||||
border-bottom: 1px solid var(--bs-border-color)
|
||||
|
||||
h6
|
||||
color: var(--bs-secondary-color)
|
||||
font-weight: 600
|
||||
display: flex
|
||||
align-items: center
|
||||
|
||||
fa-icon
|
||||
opacity: 0.7
|
||||
|
||||
.events-list
|
||||
max-height: 300px
|
||||
overflow-y: auto
|
||||
|
||||
.event-item
|
||||
display: flex
|
||||
align-items: flex-start
|
||||
padding: 0.875rem 1rem
|
||||
border-bottom: 1px solid var(--bs-border-color)
|
||||
transition: background-color 0.2s
|
||||
|
||||
&:last-child
|
||||
border-bottom: none
|
||||
|
||||
&:hover
|
||||
background: var(--bs-tertiary-bg)
|
||||
|
||||
.event-icon
|
||||
flex-shrink: 0
|
||||
width: 32px
|
||||
height: 32px
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
border-radius: 50%
|
||||
margin-right: 0.75rem
|
||||
|
||||
fa-icon
|
||||
font-size: 1.1rem
|
||||
|
||||
&.event-duplicate_skipped
|
||||
.event-icon
|
||||
background: rgba(255, 193, 7, 0.15)
|
||||
color: #ffc107
|
||||
|
||||
.event-content
|
||||
flex: 1
|
||||
min-width: 0
|
||||
|
||||
.event-message
|
||||
font-size: 0.9375rem
|
||||
color: var(--bs-body-color)
|
||||
margin-bottom: 0.25rem
|
||||
font-weight: 500
|
||||
|
||||
.event-url
|
||||
font-size: 0.8125rem
|
||||
color: var(--bs-secondary-color)
|
||||
white-space: nowrap
|
||||
overflow: hidden
|
||||
text-overflow: ellipsis
|
||||
font-family: 'Courier New', monospace
|
||||
|
||||
.event-time
|
||||
flex-shrink: 0
|
||||
font-size: 0.75rem
|
||||
color: var(--bs-secondary-color)
|
||||
margin-left: 0.75rem
|
||||
whitespace: nowrap
|
||||
.content
|
||||
padding: 14px 14px 16px
|
||||
|
|
|
|||
|
|
@ -1,31 +1,24 @@
|
|||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { CookieService } from 'ngx-cookie-service';
|
||||
import { AppComponent } from './app.component';
|
||||
import { DownloadsService } from './downloads.service';
|
||||
import { MeTubeSocket } from './metube-socket';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
AppComponent
|
||||
],
|
||||
declarations: [AppComponent],
|
||||
imports: [FormsModule],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
providers: [MeTubeSocket, CookieService, DownloadsService, provideHttpClient()]
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it(`should have as title 'metube'`, () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app.title).toEqual('metube');
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.content span').textContent).toContain('metube app is running!');
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -5,28 +5,46 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
|||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||
import { CookieService } from 'ngx-cookie-service';
|
||||
import { ScrollingModule } from '@angular/cdk/scrolling';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
import { EtaPipe, SpeedPipe, EncodeURIComponent, FileSizePipe } from './downloads.pipe';
|
||||
import { MasterCheckboxComponent, SlaveCheckboxComponent } from './master-checkbox.component';
|
||||
import { EtaPipe, SpeedPipe, EncodeURIComponent, FileSizePipe, DurationPipe } from './downloads.pipe';
|
||||
import { MeTubeSocket } from './metube-socket';
|
||||
import { NgSelectModule } from '@ng-select/ng-select';
|
||||
import { ServiceWorkerModule } from '@angular/service-worker';
|
||||
import { SidebarComponent } from './sidebar.component';
|
||||
import { CommandBarComponent } from './command-bar.component';
|
||||
import { StatsStripComponent } from './stats-strip.component';
|
||||
import { ActiveDownloadsComponent } from './active-downloads.component';
|
||||
import { DownloadListComponent } from './download-list.component';
|
||||
import { DownloadRowComponent } from './download-row.component';
|
||||
import { DownloadCardComponent } from './download-card.component';
|
||||
import { AdvancedOptionsComponent } from './advanced-options.component';
|
||||
import { ToastsComponent } from './toasts.component';
|
||||
|
||||
@NgModule({ declarations: [
|
||||
AppComponent,
|
||||
EtaPipe,
|
||||
SpeedPipe,
|
||||
FileSizePipe,
|
||||
DurationPipe,
|
||||
EncodeURIComponent,
|
||||
MasterCheckboxComponent,
|
||||
SlaveCheckboxComponent
|
||||
SidebarComponent,
|
||||
CommandBarComponent,
|
||||
StatsStripComponent,
|
||||
ActiveDownloadsComponent,
|
||||
DownloadListComponent,
|
||||
DownloadRowComponent,
|
||||
DownloadCardComponent,
|
||||
AdvancedOptionsComponent,
|
||||
ToastsComponent
|
||||
],
|
||||
bootstrap: [AppComponent], imports: [BrowserModule,
|
||||
FormsModule,
|
||||
NgbModule,
|
||||
FontAwesomeModule,
|
||||
NgSelectModule,
|
||||
ScrollingModule,
|
||||
ServiceWorkerModule.register('custom-service-worker.js', {
|
||||
enabled: !isDevMode(),
|
||||
// Register the ServiceWorker as soon as the application is stable
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
<div class="commandbar">
|
||||
<div class="cb-row">
|
||||
<button class="menu-btn" (click)="menu.emit()" aria-label="打开导航">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="url-wrap">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
<input class="url-input" type="url" placeholder="粘贴视频或播放列表 URL…" aria-label="视频 URL"
|
||||
autocomplete="off" spellcheck="false"
|
||||
[ngModel]="url" (ngModelChange)="urlChange.emit($event)"
|
||||
(keydown.enter)="add.emit(url)" [disabled]="disabled">
|
||||
</div>
|
||||
<button class="primary-btn" (click)="add.emit(url)" [disabled]="disabled">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
添加到队列
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cb-chips">
|
||||
<span class="cb-label">清晰度</span>
|
||||
<button *ngFor="let q of quickQualities" type="button" class="chip" [class.on]="quality === q.id" (click)="qualityChange.emit(q.id)">
|
||||
{{ qualityLabel(q.id) }}
|
||||
</button>
|
||||
<div class="more-wrap" *ngIf="moreQualities.length">
|
||||
<button type="button" class="chip chip-more" [class.on]="menuOpen === 'quality'" (click)="toggleMenu('quality')">
|
||||
更多
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<div class="menu" *ngIf="menuOpen === 'quality'">
|
||||
<button *ngFor="let q of moreQualities" type="button" class="menu-item" [class.on]="quality === q.id" (click)="pickQuality(q.id)">
|
||||
{{ qualityLabel(q.id) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="chip-sep"></span>
|
||||
<span class="cb-label">格式</span>
|
||||
<button *ngFor="let f of quickFormats" type="button" class="chip" [class.on]="format === f.id" (click)="formatChange.emit(f.id)">
|
||||
{{ formatLabel(f.id) }}
|
||||
</button>
|
||||
<div class="more-wrap" *ngIf="moreFormats.length">
|
||||
<button type="button" class="chip chip-more" [class.on]="menuOpen === 'format'" (click)="toggleMenu('format')">
|
||||
更多
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<div class="menu" *ngIf="menuOpen === 'format'">
|
||||
<button *ngFor="let f of moreFormats" type="button" class="menu-item" [class.on]="format === f.id" (click)="pickFormat(f.id)">
|
||||
{{ formatLabel(f.id) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="chip-sep"></span>
|
||||
<ng-select class="folder-select"
|
||||
[items]="customDirs$ | async"
|
||||
placeholder="默认目录"
|
||||
[addTag]="allowCustomDir"
|
||||
addTagText="创建目录"
|
||||
[clearable]="true"
|
||||
[searchable]="true"
|
||||
[closeOnSelect]="true"
|
||||
[disabled]="disabled"
|
||||
[(ngModel)]="folder"
|
||||
(ngModelChange)="folderChange.emit($event)"
|
||||
ngbTooltip="选择保存目录,可直接输入创建新目录">
|
||||
</ng-select>
|
||||
|
||||
<button class="advanced-link" (click)="openAdvanced.emit()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
|
||||
高级选项
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
:host
|
||||
display: block
|
||||
flex: none
|
||||
|
||||
.commandbar
|
||||
background: var(--surface)
|
||||
border-bottom: 1px solid var(--border)
|
||||
padding: 14px 22px 12px
|
||||
|
||||
.cb-row
|
||||
display: flex
|
||||
gap: 10px
|
||||
align-items: stretch
|
||||
|
||||
.url-wrap
|
||||
flex: 1
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 8px
|
||||
border: 1px solid var(--border)
|
||||
border-radius: var(--radius)
|
||||
background: var(--bg)
|
||||
padding: 0 12px
|
||||
transition: border-color 0.12s ease, background 0.12s ease
|
||||
|
||||
&:focus-within
|
||||
border-color: var(--accent)
|
||||
background: var(--surface)
|
||||
|
||||
svg
|
||||
color: var(--muted)
|
||||
flex: none
|
||||
|
||||
.url-input
|
||||
flex: 1
|
||||
border: none
|
||||
background: none
|
||||
height: 40px
|
||||
font-size: 14px
|
||||
color: var(--fg)
|
||||
outline: none
|
||||
font-family: var(--font-mono)
|
||||
|
||||
&::placeholder
|
||||
color: var(--muted)
|
||||
font-family: var(--font-body)
|
||||
|
||||
.primary-btn
|
||||
display: inline-flex
|
||||
align-items: center
|
||||
gap: 7px
|
||||
background: var(--accent)
|
||||
color: oklch(99% 0.01 145)
|
||||
border: none
|
||||
border-radius: var(--radius)
|
||||
padding: 0 18px
|
||||
height: 40px
|
||||
font-size: 14px
|
||||
font-weight: 590
|
||||
letter-spacing: 0.02em
|
||||
transition: background 0.12s ease, transform 0.06s ease
|
||||
white-space: nowrap
|
||||
|
||||
&:hover:not(:disabled)
|
||||
background: var(--accent-strong)
|
||||
|
||||
&:active:not(:disabled)
|
||||
transform: translateY(1px)
|
||||
|
||||
&:disabled
|
||||
opacity: 0.55
|
||||
cursor: default
|
||||
|
||||
.cb-chips
|
||||
display: flex
|
||||
gap: 6px
|
||||
margin-top: 10px
|
||||
align-items: center
|
||||
flex-wrap: wrap
|
||||
position: relative
|
||||
|
||||
.cb-label
|
||||
font-size: 12px
|
||||
color: var(--muted)
|
||||
margin-right: 2px
|
||||
|
||||
.chip
|
||||
display: inline-flex
|
||||
align-items: center
|
||||
gap: 4px
|
||||
border: 1px solid var(--border)
|
||||
background: var(--surface)
|
||||
border-radius: 20px
|
||||
padding: 4px 12px
|
||||
font-size: 12.5px
|
||||
color: var(--fg)
|
||||
transition: background 0.12s ease, border-color 0.12s ease
|
||||
|
||||
&:hover
|
||||
background: var(--bg)
|
||||
|
||||
&.on
|
||||
background: var(--fg)
|
||||
border-color: var(--fg)
|
||||
color: var(--surface)
|
||||
font-weight: 550
|
||||
|
||||
.chip-sep
|
||||
width: 1px
|
||||
height: 18px
|
||||
background: var(--border)
|
||||
margin: 0 4px
|
||||
|
||||
.more-wrap
|
||||
position: relative
|
||||
|
||||
.menu
|
||||
position: absolute
|
||||
top: calc(100% + 6px)
|
||||
left: 0
|
||||
z-index: 40
|
||||
min-width: 120px
|
||||
background: var(--surface)
|
||||
border: 1px solid var(--border)
|
||||
border-radius: 8px
|
||||
box-shadow: 0 8px 24px oklch(30% 0.02 240 / 0.14)
|
||||
padding: 5px
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 2px
|
||||
|
||||
.menu-item
|
||||
display: flex
|
||||
align-items: center
|
||||
width: 100%
|
||||
border: none
|
||||
background: none
|
||||
border-radius: 6px
|
||||
padding: 7px 10px
|
||||
font-size: 12.5px
|
||||
color: var(--fg)
|
||||
text-align: left
|
||||
|
||||
&:hover
|
||||
background: var(--bg)
|
||||
|
||||
&.on
|
||||
background: var(--accent-tint)
|
||||
color: var(--accent-strong)
|
||||
font-weight: 550
|
||||
|
||||
.folder-select
|
||||
display: inline-block
|
||||
min-width: 120px
|
||||
max-width: 220px
|
||||
|
||||
.advanced-link
|
||||
margin-left: auto
|
||||
border: none
|
||||
background: none
|
||||
font-size: 12.5px
|
||||
color: var(--muted)
|
||||
display: inline-flex
|
||||
align-items: center
|
||||
gap: 5px
|
||||
padding: 4px 6px
|
||||
border-radius: 6px
|
||||
|
||||
&:hover
|
||||
background: var(--bg)
|
||||
color: var(--fg)
|
||||
|
||||
.menu-btn
|
||||
display: none
|
||||
place-items: center
|
||||
width: 40px
|
||||
height: 40px
|
||||
border: 1px solid var(--border)
|
||||
background: var(--surface)
|
||||
border-radius: var(--radius)
|
||||
color: var(--fg)
|
||||
flex: none
|
||||
|
||||
@media (max-width: 900px)
|
||||
.menu-btn
|
||||
display: grid !important
|
||||
|
||||
.cb-row
|
||||
flex-wrap: wrap
|
||||
|
||||
.url-wrap
|
||||
width: 100%
|
||||
flex: none
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { Component, ElementRef, EventEmitter, HostListener, Input, Output } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Format, Quality } from './formats';
|
||||
import { formatLabel, qualityLabel } from './labels';
|
||||
|
||||
const QUICK_QUALITY_IDS = ['best', '1080', '720', 'audio'];
|
||||
const QUICK_FORMAT_IDS = ['any', 'mp4', 'm4a', 'mp3'];
|
||||
|
||||
@Component({
|
||||
selector: 'app-command-bar',
|
||||
standalone: false,
|
||||
templateUrl: './command-bar.component.html',
|
||||
styleUrls: ['./command-bar.component.sass']
|
||||
})
|
||||
export class CommandBarComponent {
|
||||
@Input() url = '';
|
||||
@Input() quality = 'best';
|
||||
@Input() format = 'any';
|
||||
@Input() folder = '';
|
||||
@Input() qualities: Quality[] = [];
|
||||
@Input() formats: Format[] = [];
|
||||
@Input() customDirs$: Observable<string[]>;
|
||||
@Input() allowCustomDir: (tag: string) => string | boolean = () => false;
|
||||
@Input() disabled = false;
|
||||
|
||||
@Output() urlChange = new EventEmitter<string>();
|
||||
@Output() add = new EventEmitter<string>();
|
||||
@Output() qualityChange = new EventEmitter<string>();
|
||||
@Output() formatChange = new EventEmitter<string>();
|
||||
@Output() folderChange = new EventEmitter<string>();
|
||||
@Output() openAdvanced = new EventEmitter<void>();
|
||||
@Output() menu = new EventEmitter<void>();
|
||||
|
||||
menuOpen: 'quality' | 'format' | null = null;
|
||||
|
||||
constructor(private el: ElementRef) {}
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
onDocClick(event: MouseEvent): void {
|
||||
if (!this.el.nativeElement.contains(event.target)) {
|
||||
this.menuOpen = null;
|
||||
}
|
||||
}
|
||||
|
||||
get quickQualities(): Quality[] {
|
||||
return this.qualities.filter(q => QUICK_QUALITY_IDS.includes(q.id));
|
||||
}
|
||||
|
||||
get moreQualities(): Quality[] {
|
||||
return this.qualities.filter(q => !QUICK_QUALITY_IDS.includes(q.id));
|
||||
}
|
||||
|
||||
get quickFormats(): Format[] {
|
||||
return this.formats.filter(f => QUICK_FORMAT_IDS.includes(f.id));
|
||||
}
|
||||
|
||||
get moreFormats(): Format[] {
|
||||
return this.formats.filter(f => !QUICK_FORMAT_IDS.includes(f.id));
|
||||
}
|
||||
|
||||
qualityLabel = qualityLabel;
|
||||
formatLabel = formatLabel;
|
||||
|
||||
folderLabel(): string {
|
||||
return this.folder || '默认目录';
|
||||
}
|
||||
|
||||
toggleMenu(which: 'quality' | 'format'): void {
|
||||
this.menuOpen = this.menuOpen === which ? null : which;
|
||||
}
|
||||
|
||||
pickQuality(id: string): void {
|
||||
this.menuOpen = null;
|
||||
this.qualityChange.emit(id);
|
||||
}
|
||||
|
||||
pickFormat(id: string): void {
|
||||
this.menuOpen = null;
|
||||
this.formatChange.emit(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<div class="card" [class.selected]="selected">
|
||||
<input type="checkbox" class="card-check" [checked]="selected" (change)="selectChange.emit($event.target.checked)" [attr.aria-label]="'选择 ' + row.value.title">
|
||||
<div class="thumb">
|
||||
<img *ngIf="row.value.thumbnail" [src]="thumbUrl()" [alt]="row.value.title" loading="lazy">
|
||||
<span class="thumb-ph" *ngIf="!row.value.thumbnail">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="10 9 15 12 10 15"/></svg>
|
||||
</span>
|
||||
<span class="dur num" *ngIf="row.value.duration">{{ row.value.duration | duration }}</span>
|
||||
<span *ngIf="row.value.file_exists === false" class="file-warn" title="文件不存在">
|
||||
<fa-icon [icon]="faTimesCircle"></fa-icon>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-title" [title]="row.value.title">{{ row.value.title }}</div>
|
||||
<div class="card-meta">
|
||||
<span class="badge folder">{{ folderLabel() }}</span>
|
||||
<span class="badge fmt">{{ qualityLabel(row.value.quality) }} · {{ formatLabel(row.value.format) }}</span>
|
||||
<span class="card-added" [title]="fullTime()">{{ relativeTime(row.value.timestamp) }}</span>
|
||||
<span class="num card-size">{{ row.value.size ? (row.value.size | fileSize) : '—' }}</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<a *ngIf="downloadUrl" class="icon-btn neutral" [href]="downloadUrl" target="_blank" title="下载文件">
|
||||
<fa-icon [icon]="faDownload"></fa-icon>
|
||||
</a>
|
||||
<button *ngIf="row.value.status === 'error'" type="button" class="icon-btn neutral" (click)="retry.emit({ key: row.key, dl: row.value })" title="重试">
|
||||
<fa-icon [icon]="faRedoAlt"></fa-icon>
|
||||
</button>
|
||||
<a class="icon-btn neutral" [href]="row.value.url" target="_blank" title="打开原链接">
|
||||
<fa-icon [icon]="faExternalLinkAlt"></fa-icon>
|
||||
</a>
|
||||
<button type="button" class="icon-btn" (click)="del.emit(row.key)" title="删除" aria-label="删除">
|
||||
<fa-icon [icon]="faTrashAlt"></fa-icon>
|
||||
</button>
|
||||
<ng-select class="card-folder-select"
|
||||
[items]="customDirs"
|
||||
placeholder="默认"
|
||||
[addTag]="allowCustomDir"
|
||||
addTagText="创建目录"
|
||||
[clearable]="true"
|
||||
[searchable]="true"
|
||||
[closeOnSelect]="true"
|
||||
[ngModel]="row.value.folder"
|
||||
(change)="folderChange.emit({ key: row.key, folder: $event || '' })"
|
||||
appendTo="body"
|
||||
ngbTooltip="移动文件到其他目录">
|
||||
</ng-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
:host
|
||||
display: block
|
||||
|
||||
.card
|
||||
background: var(--surface)
|
||||
border: 1px solid var(--border)
|
||||
border-radius: var(--radius)
|
||||
overflow: hidden
|
||||
transition: border-color 0.12s ease, box-shadow 0.12s ease
|
||||
position: relative
|
||||
height: 100%
|
||||
|
||||
&:hover
|
||||
border-color: oklch(80% 0.012 240)
|
||||
box-shadow: 0 4px 14px oklch(30% 0.02 240 / 0.08)
|
||||
|
||||
&.selected
|
||||
border-color: var(--accent)
|
||||
box-shadow: 0 0 0 1px var(--accent)
|
||||
|
||||
.thumb
|
||||
border-radius: 0
|
||||
aspect-ratio: 16 / 9
|
||||
overflow: hidden
|
||||
background: var(--placeholder)
|
||||
position: relative
|
||||
display: grid
|
||||
place-items: center
|
||||
|
||||
img
|
||||
display: block
|
||||
width: 100%
|
||||
height: 100%
|
||||
object-fit: cover
|
||||
|
||||
.thumb-ph
|
||||
color: var(--muted)
|
||||
|
||||
.dur
|
||||
position: absolute
|
||||
right: 4px
|
||||
bottom: 4px
|
||||
background: oklch(20% 0.02 240 / 0.82)
|
||||
color: oklch(96% 0.01 240)
|
||||
font-family: var(--font-mono)
|
||||
font-size: 10.5px
|
||||
padding: 1px 5px
|
||||
border-radius: 3px
|
||||
|
||||
.file-warn
|
||||
position: absolute
|
||||
left: 8px
|
||||
top: 8px
|
||||
color: var(--warn)
|
||||
background: oklch(20% 0.02 240 / 0.55)
|
||||
border-radius: 50%
|
||||
width: 22px
|
||||
height: 22px
|
||||
display: grid
|
||||
place-items: center
|
||||
|
||||
.card-body
|
||||
padding: 10px 12px 12px
|
||||
|
||||
.card-title
|
||||
font-size: 13px
|
||||
font-weight: 550
|
||||
line-height: 1.45
|
||||
display: -webkit-box
|
||||
-webkit-line-clamp: 2
|
||||
-webkit-box-orient: vertical
|
||||
overflow: hidden
|
||||
min-height: 38px
|
||||
|
||||
.card-meta
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 6px
|
||||
margin-top: 8px
|
||||
font-size: 11.5px
|
||||
color: var(--muted)
|
||||
flex-wrap: wrap
|
||||
|
||||
.num
|
||||
color: var(--fg)
|
||||
|
||||
.card-size
|
||||
margin-left: auto
|
||||
|
||||
.badge
|
||||
font-size: 10.5px
|
||||
font-weight: 550
|
||||
padding: 1px 7px
|
||||
border-radius: 20px
|
||||
border: 1px solid var(--border)
|
||||
background: var(--bg)
|
||||
color: var(--muted)
|
||||
letter-spacing: 0.02em
|
||||
white-space: nowrap
|
||||
|
||||
&.folder
|
||||
color: var(--fg)
|
||||
background: var(--surface)
|
||||
|
||||
&.fmt
|
||||
font-family: var(--font-mono)
|
||||
font-weight: 500
|
||||
|
||||
.card-added
|
||||
white-space: nowrap
|
||||
|
||||
.card-actions
|
||||
display: flex
|
||||
gap: 2px
|
||||
margin-top: 8px
|
||||
align-items: center
|
||||
|
||||
.icon-btn
|
||||
width: 28px
|
||||
height: 28px
|
||||
display: grid
|
||||
place-items: center
|
||||
border: 1px solid transparent
|
||||
background: none
|
||||
border-radius: 6px
|
||||
color: var(--muted)
|
||||
text-decoration: none
|
||||
transition: background 0.12s ease, color 0.12s ease
|
||||
font-size: 12px
|
||||
|
||||
&:hover
|
||||
background: var(--danger-tint)
|
||||
color: var(--danger)
|
||||
|
||||
&.neutral:hover
|
||||
background: var(--bg)
|
||||
color: var(--fg)
|
||||
|
||||
.card-folder-select
|
||||
width: 108px
|
||||
margin-left: auto
|
||||
|
||||
.card-check
|
||||
position: absolute
|
||||
top: 8px
|
||||
left: 8px
|
||||
z-index: 2
|
||||
width: 17px
|
||||
height: 17px
|
||||
accent-color: var(--accent)
|
||||
cursor: pointer
|
||||
opacity: 0
|
||||
transition: opacity 0.12s ease
|
||||
|
||||
.card:hover .card-check, .card-check:checked
|
||||
opacity: 1
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { faDownload, faExternalLinkAlt, faRedoAlt, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons';
|
||||
import { Download, DownloadsService } from './downloads.service';
|
||||
import { KeyedDownload } from './active-downloads.component';
|
||||
import { formatLabel, qualityLabel, relativeTime } from './labels';
|
||||
|
||||
@Component({
|
||||
selector: 'app-download-card',
|
||||
standalone: false,
|
||||
templateUrl: './download-card.component.html',
|
||||
styleUrls: ['./download-card.component.sass']
|
||||
})
|
||||
export class DownloadCardComponent {
|
||||
@Input() row: KeyedDownload;
|
||||
@Input() selected = false;
|
||||
@Input() customDirs: string[] = [];
|
||||
@Input() allowCustomDir: (tag: string) => string | boolean = () => false;
|
||||
@Input() downloadUrl = '';
|
||||
|
||||
@Output() selectChange = new EventEmitter<boolean>();
|
||||
@Output() del = new EventEmitter<string>();
|
||||
@Output() retry = new EventEmitter<{ key: string; dl: Download }>();
|
||||
@Output() folderChange = new EventEmitter<{ key: string; folder: string }>();
|
||||
|
||||
faDownload = faDownload;
|
||||
faExternalLinkAlt = faExternalLinkAlt;
|
||||
faRedoAlt = faRedoAlt;
|
||||
faTrashAlt = faTrashAlt;
|
||||
faCheckCircle = faCheckCircle;
|
||||
faTimesCircle = faTimesCircle;
|
||||
qualityLabel = qualityLabel;
|
||||
formatLabel = formatLabel;
|
||||
relativeTime = relativeTime;
|
||||
|
||||
constructor(private downloads: DownloadsService) {}
|
||||
|
||||
thumbUrl(): string {
|
||||
return this.downloads.thumbnailUrl(this.row.key);
|
||||
}
|
||||
|
||||
folderLabel(): string {
|
||||
return this.row.value.folder || '默认';
|
||||
}
|
||||
|
||||
fullTime(): string {
|
||||
return new Date((this.row.value.timestamp || 0) / 1e6).toLocaleString();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
<section class="list-section">
|
||||
<div class="section-head">
|
||||
<h2 class="section-title">{{ title }}</h2>
|
||||
<span class="section-meta num" *ngIf="status !== 'downloading'">{{ subtitle }}</span>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar" *ngIf="status !== 'downloading'">
|
||||
<div class="filter-wrap">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input type="search" placeholder="筛选标题…" aria-label="筛选标题"
|
||||
[ngModel]="filterText" (ngModelChange)="filterChange.emit($event)">
|
||||
</div>
|
||||
<select class="tool-select" aria-label="排序方式" [ngModel]="sort" (ngModelChange)="sortChange.emit($event)">
|
||||
<option value="date">按添加时间</option>
|
||||
<option value="title">按标题</option>
|
||||
<option value="size">按大小</option>
|
||||
</select>
|
||||
<button class="sort-dir" (click)="sortAscendingChange.emit(!sortAscending)" [title]="sortAscending ? '升序,点击切换' : '降序,点击切换'">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline [attr.points]="sortAscending ? '6 15 12 9 18 15' : '6 9 12 15 18 9'"/></svg>
|
||||
</button>
|
||||
<div class="view-toggle" *ngIf="status === 'completed'" aria-label="视图切换">
|
||||
<button type="button" [class.on]="view === 'list'" (click)="viewChange.emit('list')" title="列表视图" aria-label="列表视图">
|
||||
<fa-icon [icon]="faList"></fa-icon>
|
||||
</button>
|
||||
<button type="button" [class.on]="view === 'grid'" (click)="viewChange.emit('grid')" title="网格视图" aria-label="网格视图">
|
||||
<fa-icon [icon]="faThLarge"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
<button class="ghost-btn" (click)="toggleSelectAll()">全选</button>
|
||||
<span class="result-count num">显示 {{ rows.length }} / {{ rows.length }}</span>
|
||||
<ng-container *ngIf="status === 'completed'">
|
||||
<button class="ghost-btn" (click)="clearCompleted.emit()">清除已完成</button>
|
||||
<button class="ghost-btn" (click)="clearFailed.emit()">清除失败</button>
|
||||
<button class="ghost-btn" (click)="retryFailed.emit()">重试失败</button>
|
||||
<button class="ghost-btn" [disabled]="selected.size === 0" (click)="downloadSelected.emit(Array.from(selected))">下载所选</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="!loading; else skeletonTpl">
|
||||
<div class="empty" *ngIf="status === 'downloading'">
|
||||
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<div class="empty-title">下载任务固定显示在上方</div>
|
||||
<div class="empty-sub">进行中的任务会实时显示在「正在下载」面板</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="status !== 'downloading'">
|
||||
<div class="list-frame" *ngIf="rows.length; else emptyTpl">
|
||||
<cdk-virtual-scroll-viewport class="viewport" [itemSize]="ROW_H" *ngIf="status === 'queued' || view === 'list'">
|
||||
<div class="row-slot" *cdkVirtualFor="let row of rows; trackBy: trackByKey">
|
||||
<app-download-row
|
||||
[row]="row"
|
||||
[status]="status"
|
||||
[selected]="selected.has(row.key)"
|
||||
[customDirs]="customDirsFor(row.value)"
|
||||
[allowCustomDir]="allowCustomDir"
|
||||
[downloadUrl]="status === 'completed' ? downloadUrlFor(row.value) : ''"
|
||||
(selectChange)="toggleSelect(row.key, $event)"
|
||||
(start)="start.emit([$event])"
|
||||
(del)="del.emit([$event])"
|
||||
(retry)="retry.emit($event)"
|
||||
(folderChange)="folderEdit.emit($event)">
|
||||
</app-download-row>
|
||||
</div>
|
||||
</cdk-virtual-scroll-viewport>
|
||||
<div class="grid-wrap" *ngIf="status === 'completed' && view === 'grid'">
|
||||
<div class="grid">
|
||||
<app-download-card
|
||||
*ngFor="let row of rows; trackBy: trackByKey"
|
||||
[row]="row"
|
||||
[selected]="selected.has(row.key)"
|
||||
[customDirs]="customDirsFor(row.value)"
|
||||
[allowCustomDir]="allowCustomDir"
|
||||
[downloadUrl]="downloadUrlFor(row.value)"
|
||||
(selectChange)="toggleSelect(row.key, $event)"
|
||||
(del)="del.emit([$event])"
|
||||
(retry)="retry.emit($event)"
|
||||
(folderChange)="folderEdit.emit($event)">
|
||||
</app-download-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #emptyTpl>
|
||||
<div class="empty">
|
||||
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<div class="empty-title">{{ emptyTitle }}</div>
|
||||
<div class="empty-sub">{{ emptySub }}</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #skeletonTpl>
|
||||
<div class="list-frame">
|
||||
<div class="sk-row" *ngFor="let _ of [0, 1, 2, 3, 4, 5]">
|
||||
<div class="sk-check shimmer"></div>
|
||||
<div class="sk-thumb shimmer"></div>
|
||||
<div class="sk-lines">
|
||||
<div class="shimmer line w60"></div>
|
||||
<div class="shimmer line w35"></div>
|
||||
</div>
|
||||
<div class="sk-side shimmer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<div class="batchbar" [class.show]="selected.size > 0">
|
||||
<span class="batch-count">已选 {{ selected.size }} 项</span>
|
||||
<button *ngIf="status === 'queued'" type="button" class="batch-btn" (click)="startSelected()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="6 3 20 12 6 21 6 3"/></svg>
|
||||
开始
|
||||
</button>
|
||||
<div class="move-wrap">
|
||||
<button type="button" class="batch-btn" (click)="moveMenuOpen = !moveMenuOpen">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
移动到…
|
||||
</button>
|
||||
<div class="move-menu" *ngIf="moveMenuOpen">
|
||||
<button *ngFor="let folder of folderOptions()" type="button" class="move-item" (click)="moveTo(folder)">{{ folder }}</button>
|
||||
<button *ngIf="!folderOptions().length" type="button" class="move-item" disabled>暂无可移动的目录</button>
|
||||
<div class="move-new">
|
||||
<input type="text" placeholder="新建目录…" [(ngModel)]="newFolder" (keydown.enter)="confirmNewFolder()">
|
||||
<button type="button" class="batch-btn small" (click)="confirmNewFolder()">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="batch-btn danger" (click)="delSelected()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
删除
|
||||
</button>
|
||||
<button type="button" class="batch-btn" (click)="clearSelection()">取消选择</button>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,361 @@
|
|||
:host
|
||||
display: flex
|
||||
flex-direction: column
|
||||
flex: 1
|
||||
min-height: 160px
|
||||
|
||||
.list-section
|
||||
display: flex
|
||||
flex-direction: column
|
||||
flex: 1
|
||||
min-height: 0
|
||||
|
||||
.section-head
|
||||
display: flex
|
||||
align-items: baseline
|
||||
gap: 10px
|
||||
margin-bottom: 10px
|
||||
flex: none
|
||||
|
||||
.section-title
|
||||
font-family: var(--font-display)
|
||||
font-size: 15px
|
||||
font-weight: 600
|
||||
letter-spacing: -0.01em
|
||||
line-height: 1.35
|
||||
margin: 0
|
||||
|
||||
.section-meta
|
||||
font-size: 12px
|
||||
color: var(--muted)
|
||||
font-family: var(--font-mono)
|
||||
|
||||
.list-toolbar
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 8px
|
||||
margin-bottom: 12px
|
||||
flex-wrap: wrap
|
||||
flex: none
|
||||
|
||||
.filter-wrap
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 7px
|
||||
border: 1px solid var(--border)
|
||||
background: var(--surface)
|
||||
border-radius: 6px
|
||||
padding: 0 10px
|
||||
height: 34px
|
||||
width: 240px
|
||||
transition: border-color 0.12s ease
|
||||
|
||||
&:focus-within
|
||||
border-color: var(--accent)
|
||||
|
||||
svg
|
||||
color: var(--muted)
|
||||
flex: none
|
||||
|
||||
input
|
||||
border: none
|
||||
background: none
|
||||
outline: none
|
||||
font-size: 13px
|
||||
color: var(--fg)
|
||||
width: 100%
|
||||
|
||||
&::placeholder
|
||||
color: var(--muted)
|
||||
|
||||
.tool-select
|
||||
height: 34px
|
||||
border: 1px solid var(--border)
|
||||
background: var(--surface)
|
||||
border-radius: 6px
|
||||
font-size: 12.5px
|
||||
color: var(--fg)
|
||||
padding: 0 8px
|
||||
|
||||
.sort-dir
|
||||
width: 34px
|
||||
height: 34px
|
||||
display: grid
|
||||
place-items: center
|
||||
border: 1px solid var(--border)
|
||||
background: var(--surface)
|
||||
border-radius: 6px
|
||||
color: var(--muted)
|
||||
transition: background 0.12s ease, color 0.12s ease
|
||||
|
||||
&:hover
|
||||
background: var(--bg)
|
||||
color: var(--fg)
|
||||
|
||||
.view-toggle
|
||||
display: flex
|
||||
border: 1px solid var(--border)
|
||||
border-radius: 6px
|
||||
overflow: hidden
|
||||
background: var(--surface)
|
||||
|
||||
button
|
||||
width: 34px
|
||||
height: 32px
|
||||
border: none
|
||||
background: none
|
||||
display: grid
|
||||
place-items: center
|
||||
color: var(--muted)
|
||||
transition: background 0.12s ease, color 0.12s ease
|
||||
|
||||
& + button
|
||||
border-left: 1px solid var(--border)
|
||||
|
||||
&.on
|
||||
background: var(--fg)
|
||||
color: var(--surface)
|
||||
|
||||
&:not(.on):hover
|
||||
background: var(--bg)
|
||||
color: var(--fg)
|
||||
|
||||
.ghost-btn
|
||||
display: inline-flex
|
||||
align-items: center
|
||||
gap: 7px
|
||||
padding: 6px 10px
|
||||
border: 1px solid var(--border)
|
||||
background: var(--surface)
|
||||
border-radius: 6px
|
||||
font-size: 12.5px
|
||||
color: var(--fg)
|
||||
transition: background 0.12s ease, border-color 0.12s ease
|
||||
|
||||
&:hover
|
||||
background: var(--bg)
|
||||
border-color: oklch(82% 0.01 240)
|
||||
|
||||
.result-count
|
||||
margin-left: auto
|
||||
font-size: 12px
|
||||
color: var(--muted)
|
||||
font-family: var(--font-mono)
|
||||
|
||||
.list-frame
|
||||
flex: 1
|
||||
min-height: 0
|
||||
display: flex
|
||||
flex-direction: column
|
||||
background: var(--surface)
|
||||
border: 1px solid var(--border)
|
||||
border-radius: var(--radius)
|
||||
overflow: hidden
|
||||
|
||||
.viewport
|
||||
flex: 1
|
||||
min-height: 0
|
||||
|
||||
.row-slot
|
||||
height: 66px
|
||||
overflow: hidden
|
||||
|
||||
.grid-wrap
|
||||
flex: 1
|
||||
min-height: 0
|
||||
overflow-y: auto
|
||||
padding: 14px
|
||||
|
||||
.grid
|
||||
display: grid
|
||||
grid-template-columns: repeat(auto-fill, minmax(218px, 1fr))
|
||||
gap: 14px
|
||||
|
||||
.empty
|
||||
text-align: center
|
||||
padding: 56px 20px
|
||||
border: 1px dashed var(--border)
|
||||
border-radius: var(--radius)
|
||||
background: var(--surface)
|
||||
|
||||
svg
|
||||
color: var(--muted)
|
||||
margin-bottom: 12px
|
||||
|
||||
.empty-title
|
||||
font-size: 14px
|
||||
font-weight: 600
|
||||
margin-bottom: 4px
|
||||
|
||||
.empty-sub
|
||||
font-size: 12.5px
|
||||
color: var(--muted)
|
||||
|
||||
// Skeleton
|
||||
.sk-row
|
||||
display: grid
|
||||
grid-template-columns: 30px 88px 1fr 60px
|
||||
gap: 12px
|
||||
align-items: center
|
||||
padding: 10px 14px 10px 14px
|
||||
border-bottom: 1px solid var(--border)
|
||||
|
||||
.shimmer
|
||||
background: linear-gradient(90deg, var(--placeholder) 25%, oklch(90% 0.008 240) 50%, var(--placeholder) 75%)
|
||||
background-size: 200% 100%
|
||||
animation: shimmer 1.4s ease infinite
|
||||
|
||||
[data-bs-theme="dark"] .shimmer
|
||||
background: linear-gradient(90deg, var(--placeholder) 25%, oklch(26% 0.014 250) 50%, var(--placeholder) 75%)
|
||||
background-size: 200% 100%
|
||||
|
||||
.sk-check
|
||||
width: 17px
|
||||
height: 17px
|
||||
border-radius: 4px
|
||||
|
||||
.sk-thumb
|
||||
width: 88px
|
||||
aspect-ratio: 16 / 9
|
||||
border-radius: 5px
|
||||
|
||||
.sk-lines
|
||||
.line
|
||||
height: 10px
|
||||
border-radius: 5px
|
||||
margin-bottom: 8px
|
||||
|
||||
&.w60
|
||||
width: 60%
|
||||
|
||||
&.w35
|
||||
width: 35%
|
||||
|
||||
.sk-side
|
||||
width: 50px
|
||||
height: 10px
|
||||
border-radius: 5px
|
||||
|
||||
@keyframes shimmer
|
||||
from
|
||||
background-position: 200% 0
|
||||
to
|
||||
background-position: -200% 0
|
||||
|
||||
// Batch bar
|
||||
.batchbar
|
||||
position: fixed
|
||||
left: 50%
|
||||
bottom: 18px
|
||||
transform: translate(-50%, 76px)
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 6px
|
||||
background: var(--fg)
|
||||
color: oklch(96% 0.008 240)
|
||||
border-radius: 10px
|
||||
padding: 8px 8px 8px 16px
|
||||
box-shadow: 0 10px 30px oklch(20% 0.02 240 / 0.3)
|
||||
transition: transform 0.2s cubic-bezier(0.2, 0.9, 0.3, 1.2)
|
||||
z-index: 30
|
||||
pointer-events: none
|
||||
|
||||
&.show
|
||||
transform: translate(-50%, 0)
|
||||
pointer-events: auto
|
||||
|
||||
.batch-count
|
||||
font-size: 13px
|
||||
font-weight: 550
|
||||
margin-right: 8px
|
||||
white-space: nowrap
|
||||
|
||||
.batch-btn
|
||||
display: inline-flex
|
||||
align-items: center
|
||||
gap: 6px
|
||||
border: none
|
||||
background: oklch(100% 0 0 / 0.1)
|
||||
color: oklch(96% 0.008 240)
|
||||
border-radius: 6px
|
||||
padding: 7px 12px
|
||||
font-size: 12.5px
|
||||
font-weight: 500
|
||||
transition: background 0.12s ease
|
||||
white-space: nowrap
|
||||
|
||||
&:hover
|
||||
background: oklch(100% 0 0 / 0.2)
|
||||
|
||||
&.danger:hover
|
||||
background: var(--danger)
|
||||
|
||||
&.small
|
||||
padding: 6px 10px
|
||||
font-size: 12px
|
||||
|
||||
.move-wrap
|
||||
position: relative
|
||||
|
||||
.move-menu
|
||||
position: absolute
|
||||
bottom: calc(100% + 8px)
|
||||
left: 0
|
||||
z-index: 35
|
||||
min-width: 190px
|
||||
background: var(--surface)
|
||||
border: 1px solid var(--border)
|
||||
border-radius: 8px
|
||||
box-shadow: 0 8px 24px oklch(30% 0.02 240 / 0.18)
|
||||
padding: 6px
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 2px
|
||||
color: var(--fg)
|
||||
|
||||
.move-item
|
||||
width: 100%
|
||||
border: none
|
||||
background: none
|
||||
border-radius: 6px
|
||||
padding: 7px 10px
|
||||
font-size: 12.5px
|
||||
color: var(--fg)
|
||||
text-align: left
|
||||
|
||||
&:hover:not(:disabled)
|
||||
background: var(--bg)
|
||||
|
||||
&:disabled
|
||||
color: var(--muted)
|
||||
|
||||
.move-new
|
||||
display: flex
|
||||
gap: 6px
|
||||
padding: 6px 2px 2px
|
||||
border-top: 1px solid var(--border)
|
||||
margin-top: 4px
|
||||
|
||||
input
|
||||
flex: 1
|
||||
min-width: 0
|
||||
border: 1px solid var(--border)
|
||||
background: var(--bg)
|
||||
border-radius: 6px
|
||||
padding: 5px 8px
|
||||
font-size: 12.5px
|
||||
color: var(--fg)
|
||||
outline: none
|
||||
|
||||
&:focus
|
||||
border-color: var(--accent)
|
||||
|
||||
@media (max-width: 900px)
|
||||
.filter-wrap
|
||||
width: 100%
|
||||
|
||||
.result-count
|
||||
margin-left: 0
|
||||
|
||||
.grid
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr))
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
|
||||
import { faList, faThLarge } from '@fortawesome/free-solid-svg-icons';
|
||||
import { Download } from './downloads.service';
|
||||
import { KeyedDownload } from './active-downloads.component';
|
||||
|
||||
export type ListStatus = 'downloading' | 'queued' | 'completed';
|
||||
|
||||
@Component({
|
||||
selector: 'app-download-list',
|
||||
standalone: false,
|
||||
templateUrl: './download-list.component.html',
|
||||
styleUrls: ['./download-list.component.sass']
|
||||
})
|
||||
export class DownloadListComponent implements OnChanges {
|
||||
@Input() status: ListStatus = 'queued';
|
||||
@Input() rows: KeyedDownload[] = [];
|
||||
@Input() folderName = '全部';
|
||||
@Input() filterText = '';
|
||||
@Input() sort = 'date';
|
||||
@Input() sortAscending = false;
|
||||
@Input() view: 'list' | 'grid' = 'list';
|
||||
@Input() loading = false;
|
||||
@Input() customDirsFor: (dl: Download) => string[] = () => [];
|
||||
@Input() allowCustomDir: (tag: string) => string | boolean = () => false;
|
||||
@Input() downloadUrlFor: (dl: Download) => string = () => '';
|
||||
|
||||
@Output() filterChange = new EventEmitter<string>();
|
||||
@Output() sortChange = new EventEmitter<string>();
|
||||
@Output() sortAscendingChange = new EventEmitter<boolean>();
|
||||
@Output() viewChange = new EventEmitter<'list' | 'grid'>();
|
||||
@Output() start = new EventEmitter<string[]>();
|
||||
@Output() del = new EventEmitter<string[]>();
|
||||
@Output() move = new EventEmitter<{ ids: string[]; folder: string }>();
|
||||
@Output() retry = new EventEmitter<{ key: string; dl: Download }>();
|
||||
@Output() folderEdit = new EventEmitter<{ key: string; folder: string }>();
|
||||
@Output() clearCompleted = new EventEmitter<void>();
|
||||
@Output() clearFailed = new EventEmitter<void>();
|
||||
@Output() retryFailed = new EventEmitter<void>();
|
||||
@Output() downloadSelected = new EventEmitter<string[]>();
|
||||
|
||||
faList = faList;
|
||||
faThLarge = faThLarge;
|
||||
|
||||
selected = new Set<string>();
|
||||
moveMenuOpen = false;
|
||||
newFolder = '';
|
||||
readonly ROW_H = 66;
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
// Navigating to another status or folder resets the selection.
|
||||
if (changes['status'] || changes['folderName']) {
|
||||
this.clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
return this.status === 'downloading' ? '正在下载' : this.status === 'queued' ? '排队中' : '已完成';
|
||||
}
|
||||
|
||||
get subtitle(): string {
|
||||
return `${this.folderName} · ${this.rows.length} 项`;
|
||||
}
|
||||
|
||||
get emptyTitle(): string {
|
||||
if (this.filterText) return '没有匹配的条目';
|
||||
return this.status === 'queued' ? '队列为空' : '暂无已完成的下载';
|
||||
}
|
||||
|
||||
get emptySub(): string {
|
||||
if (this.filterText) return '试试清除筛选条件,或切换到其他文件夹';
|
||||
return this.status === 'queued' ? '粘贴 URL 后即可加入下载队列' : '下载完成的任务会出现在这里';
|
||||
}
|
||||
|
||||
trackByKey(_: number, row: KeyedDownload): string {
|
||||
return row.key;
|
||||
}
|
||||
|
||||
toggleSelect(key: string, checked: boolean): void {
|
||||
if (checked) {
|
||||
this.selected.add(key);
|
||||
} else {
|
||||
this.selected.delete(key);
|
||||
}
|
||||
this.moveMenuOpen = false;
|
||||
}
|
||||
|
||||
allSelected(): boolean {
|
||||
return this.rows.length > 0 && this.rows.every(r => this.selected.has(r.key));
|
||||
}
|
||||
|
||||
toggleSelectAll(): void {
|
||||
if (this.allSelected()) {
|
||||
this.selected.clear();
|
||||
} else {
|
||||
this.rows.forEach(r => this.selected.add(r.key));
|
||||
}
|
||||
this.moveMenuOpen = false;
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selected.clear();
|
||||
this.moveMenuOpen = false;
|
||||
}
|
||||
|
||||
startSelected(): void {
|
||||
this.start.emit(Array.from(this.selected));
|
||||
this.clearSelection();
|
||||
}
|
||||
|
||||
delSelected(): void {
|
||||
this.del.emit(Array.from(this.selected));
|
||||
this.clearSelection();
|
||||
}
|
||||
|
||||
moveTo(folder: string): void {
|
||||
this.move.emit({ ids: Array.from(this.selected), folder });
|
||||
this.clearSelection();
|
||||
}
|
||||
|
||||
folderOptions(): string[] {
|
||||
const set = new Set<string>();
|
||||
this.rows.forEach(r => {
|
||||
if (r.value.folder) set.add(r.value.folder);
|
||||
});
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
confirmNewFolder(): void {
|
||||
const folder = this.newFolder.trim();
|
||||
if (folder) this.moveTo(folder);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<div class="q-row" [class.selected]="selected">
|
||||
<input type="checkbox" class="q-check" [checked]="selected" (change)="selectChange.emit($event.target.checked)" [attr.aria-label]="'选择 ' + row.value.title">
|
||||
<div class="thumb">
|
||||
<img *ngIf="row.value.thumbnail" [src]="thumbUrl()" [alt]="row.value.title" loading="lazy">
|
||||
<span class="thumb-ph" *ngIf="!row.value.thumbnail">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="10 9 15 12 10 15"/></svg>
|
||||
</span>
|
||||
<span class="dur num" *ngIf="row.value.duration">{{ row.value.duration | duration }}</span>
|
||||
</div>
|
||||
<div class="q-main">
|
||||
<div class="q-title" [title]="row.value.title">{{ row.value.title }}</div>
|
||||
<div class="q-meta">
|
||||
<span class="badge folder">{{ folderLabel() }}</span>
|
||||
<span class="badge fmt">{{ qualityLabel(row.value.quality) }} · {{ formatLabel(row.value.format) }}</span>
|
||||
<span class="q-added" [title]="fullTime()">{{ relativeTime(row.value.timestamp) }}</span>
|
||||
<span *ngIf="status === 'completed' && row.value.file_exists === true" class="file-badge ok" title="文件存在">
|
||||
<fa-icon [icon]="faCheckCircle"></fa-icon>
|
||||
</span>
|
||||
<span *ngIf="status === 'completed' && row.value.file_exists === false" class="file-badge missing" title="文件不存在">
|
||||
<fa-icon [icon]="faTimesCircle"></fa-icon>
|
||||
</span>
|
||||
<ng-select class="row-folder-select"
|
||||
[items]="customDirs"
|
||||
placeholder="默认"
|
||||
[addTag]="allowCustomDir"
|
||||
addTagText="创建目录"
|
||||
[clearable]="true"
|
||||
[searchable]="true"
|
||||
[closeOnSelect]="true"
|
||||
[ngModel]="row.value.folder"
|
||||
(change)="folderChange.emit({ key: row.key, folder: $event || '' })"
|
||||
appendTo="body"
|
||||
ngbTooltip="更改下载目录">
|
||||
</ng-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="q-side"><span class="num">{{ row.value.size ? (row.value.size | fileSize) : '—' }}</span></div>
|
||||
<div class="q-actions">
|
||||
<button *ngIf="status === 'queued'" type="button" class="icon-btn neutral" (click)="start.emit(row.key)" title="立即开始" aria-label="立即开始">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="6 3 20 12 6 21 6 3"/></svg>
|
||||
</button>
|
||||
<a *ngIf="status === 'completed' && downloadUrl" class="icon-btn neutral" [href]="downloadUrl" target="_blank" title="下载文件">
|
||||
<fa-icon [icon]="faDownload"></fa-icon>
|
||||
</a>
|
||||
<button *ngIf="status === 'completed' && row.value.status === 'error'" type="button" class="icon-btn neutral" (click)="retry.emit({ key: row.key, dl: row.value })" title="重试">
|
||||
<fa-icon [icon]="faRedoAlt"></fa-icon>
|
||||
</button>
|
||||
<a class="icon-btn neutral" [href]="row.value.url" target="_blank" title="打开原链接">
|
||||
<fa-icon [icon]="faExternalLinkAlt"></fa-icon>
|
||||
</a>
|
||||
<button type="button" class="icon-btn" (click)="del.emit(row.key)" title="删除" aria-label="删除">
|
||||
<fa-icon [icon]="faTrashAlt"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
:host
|
||||
display: block
|
||||
height: 100%
|
||||
|
||||
.q-row
|
||||
display: grid
|
||||
grid-template-columns: 30px 88px 1fr auto auto
|
||||
gap: 12px
|
||||
align-items: center
|
||||
padding: 8px 14px 8px 8px
|
||||
border-bottom: 1px solid var(--border)
|
||||
height: 100%
|
||||
box-sizing: border-box
|
||||
transition: background 0.1s ease
|
||||
|
||||
&:hover
|
||||
background: var(--bg)
|
||||
|
||||
&.selected
|
||||
background: var(--accent-tint)
|
||||
|
||||
.q-check
|
||||
width: 17px
|
||||
height: 17px
|
||||
accent-color: var(--accent)
|
||||
cursor: pointer
|
||||
margin-left: 6px
|
||||
|
||||
.thumb
|
||||
border-radius: 5px
|
||||
overflow: hidden
|
||||
background: var(--placeholder)
|
||||
position: relative
|
||||
aspect-ratio: 16 / 9
|
||||
display: grid
|
||||
place-items: center
|
||||
|
||||
img
|
||||
display: block
|
||||
width: 100%
|
||||
height: 100%
|
||||
object-fit: cover
|
||||
|
||||
.thumb-ph
|
||||
color: var(--muted)
|
||||
|
||||
.dur
|
||||
position: absolute
|
||||
right: 4px
|
||||
bottom: 4px
|
||||
background: oklch(20% 0.02 240 / 0.82)
|
||||
color: oklch(96% 0.01 240)
|
||||
font-family: var(--font-mono)
|
||||
font-size: 10.5px
|
||||
padding: 1px 5px
|
||||
border-radius: 3px
|
||||
|
||||
.q-main
|
||||
min-width: 0
|
||||
|
||||
.q-title
|
||||
font-size: 13.5px
|
||||
font-weight: 500
|
||||
line-height: 1.4
|
||||
display: -webkit-box
|
||||
-webkit-line-clamp: 1
|
||||
-webkit-box-orient: vertical
|
||||
overflow: hidden
|
||||
|
||||
.q-meta
|
||||
display: flex
|
||||
gap: 8px
|
||||
align-items: center
|
||||
margin-top: 3px
|
||||
font-size: 11.5px
|
||||
color: var(--muted)
|
||||
min-width: 0
|
||||
|
||||
.badge
|
||||
font-size: 10.5px
|
||||
font-weight: 550
|
||||
padding: 1px 7px
|
||||
border-radius: 20px
|
||||
border: 1px solid var(--border)
|
||||
background: var(--bg)
|
||||
color: var(--muted)
|
||||
letter-spacing: 0.02em
|
||||
white-space: nowrap
|
||||
|
||||
&.folder
|
||||
color: var(--fg)
|
||||
background: var(--surface)
|
||||
|
||||
&.fmt
|
||||
font-family: var(--font-mono)
|
||||
font-weight: 500
|
||||
|
||||
.q-added
|
||||
white-space: nowrap
|
||||
|
||||
.file-badge
|
||||
display: inline-flex
|
||||
color: var(--accent-strong)
|
||||
|
||||
&.missing
|
||||
color: var(--warn)
|
||||
|
||||
.row-folder-select
|
||||
width: 130px
|
||||
flex: none
|
||||
|
||||
.q-side
|
||||
text-align: right
|
||||
font-size: 11.5px
|
||||
color: var(--muted)
|
||||
white-space: nowrap
|
||||
|
||||
.num
|
||||
color: var(--fg)
|
||||
font-size: 12px
|
||||
|
||||
.q-actions
|
||||
display: flex
|
||||
gap: 2px
|
||||
opacity: 0
|
||||
transition: opacity 0.12s ease
|
||||
|
||||
.q-row:hover .q-actions, .q-row.selected .q-actions
|
||||
opacity: 1
|
||||
|
||||
.icon-btn
|
||||
width: 32px
|
||||
height: 32px
|
||||
display: grid
|
||||
place-items: center
|
||||
border: 1px solid transparent
|
||||
background: none
|
||||
border-radius: 6px
|
||||
color: var(--muted)
|
||||
text-decoration: none
|
||||
transition: background 0.12s ease, color 0.12s ease
|
||||
|
||||
&:hover
|
||||
background: var(--danger-tint)
|
||||
color: var(--danger)
|
||||
|
||||
&.neutral:hover
|
||||
background: var(--bg)
|
||||
color: var(--fg)
|
||||
|
||||
@media (max-width: 900px)
|
||||
.q-row
|
||||
grid-template-columns: 30px 64px 1fr auto
|
||||
|
||||
.q-side
|
||||
display: none
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { faDownload, faExternalLinkAlt, faRedoAlt, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons';
|
||||
import { Download, DownloadsService } from './downloads.service';
|
||||
import { KeyedDownload } from './active-downloads.component';
|
||||
import { formatLabel, qualityLabel, relativeTime } from './labels';
|
||||
|
||||
@Component({
|
||||
selector: 'app-download-row',
|
||||
standalone: false,
|
||||
templateUrl: './download-row.component.html',
|
||||
styleUrls: ['./download-row.component.sass']
|
||||
})
|
||||
export class DownloadRowComponent {
|
||||
@Input() row: KeyedDownload;
|
||||
@Input() status: 'queued' | 'completed' = 'queued';
|
||||
@Input() selected = false;
|
||||
@Input() customDirs: string[] = [];
|
||||
@Input() allowCustomDir: (tag: string) => string | boolean = () => false;
|
||||
@Input() downloadUrl = '';
|
||||
|
||||
@Output() selectChange = new EventEmitter<boolean>();
|
||||
@Output() start = new EventEmitter<string>();
|
||||
@Output() del = new EventEmitter<string>();
|
||||
@Output() retry = new EventEmitter<{ key: string; dl: Download }>();
|
||||
@Output() folderChange = new EventEmitter<{ key: string; folder: string }>();
|
||||
|
||||
faDownload = faDownload;
|
||||
faExternalLinkAlt = faExternalLinkAlt;
|
||||
faRedoAlt = faRedoAlt;
|
||||
faTrashAlt = faTrashAlt;
|
||||
faCheckCircle = faCheckCircle;
|
||||
faTimesCircle = faTimesCircle;
|
||||
qualityLabel = qualityLabel;
|
||||
formatLabel = formatLabel;
|
||||
relativeTime = relativeTime;
|
||||
|
||||
constructor(private downloads: DownloadsService) {}
|
||||
|
||||
thumbUrl(): string {
|
||||
return this.downloads.thumbnailUrl(this.row.key);
|
||||
}
|
||||
|
||||
folderLabel(): string {
|
||||
return this.row.value.folder || '默认';
|
||||
}
|
||||
|
||||
fullTime(): string {
|
||||
return new Date((this.row.value.timestamp || 0) / 1e6).toLocaleString();
|
||||
}
|
||||
}
|
||||
|
|
@ -91,3 +91,22 @@ export class FileSizePipe implements PipeTransform {
|
|||
return `${unitValue.toFixed(2)} ${units[unitIndex]}`;
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'duration',
|
||||
standalone: false
|
||||
})
|
||||
export class DurationPipe implements PipeTransform {
|
||||
transform(value: number, ...args: any[]): any {
|
||||
if (value === null || value === undefined || isNaN(value) || value <= 0) {
|
||||
return '';
|
||||
}
|
||||
const total = Math.round(value);
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes);
|
||||
const ss = String(seconds).padStart(2, '0');
|
||||
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ export interface Download {
|
|||
speed: number;
|
||||
eta: number;
|
||||
filename: string;
|
||||
size?: number;
|
||||
duration?: number;
|
||||
thumbnail?: string;
|
||||
timestamp?: number;
|
||||
website?: string;
|
||||
file_exists?: boolean;
|
||||
|
|
@ -166,6 +169,23 @@ export class DownloadsService {
|
|||
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
|
||||
return this.delById(where, ids);
|
||||
}
|
||||
|
||||
public moveById(ids: string[], folder: string) {
|
||||
return this.http.post<Status>('set_folder', {ids: ids, folder: folder}).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
}
|
||||
|
||||
public setCookie(cookie: string, domain = '', url = '') {
|
||||
return this.http.post<Status>('cookie', {url: url, domain: domain, cookie: cookie}).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
}
|
||||
|
||||
public thumbnailUrl(id: string): string {
|
||||
return 'thumbnail?id=' + encodeURIComponent(id);
|
||||
}
|
||||
|
||||
public addDownloadByUrl(url: string): Promise<any> {
|
||||
const defaultQuality = 'best';
|
||||
const defaultFormat = 'mp4';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
const QUALITY_LABELS: { [id: string]: string } = {
|
||||
best: '最佳',
|
||||
worst: '最差',
|
||||
best_ios: 'iOS 兼容',
|
||||
audio: '仅音频',
|
||||
'2160': '2160p',
|
||||
'1440': '1440p',
|
||||
'1080': '1080p',
|
||||
'720': '720p',
|
||||
'480': '480p',
|
||||
'360': '360p',
|
||||
'240': '240p'
|
||||
};
|
||||
|
||||
const FORMAT_LABELS: { [id: string]: string } = {
|
||||
any: '自动',
|
||||
mp4: 'MP4',
|
||||
m4a: 'M4A',
|
||||
mp3: 'MP3',
|
||||
opus: 'OPUS',
|
||||
wav: 'WAV',
|
||||
flac: 'FLAC',
|
||||
thumbnail: '封面'
|
||||
};
|
||||
|
||||
export function qualityLabel(id: string): string {
|
||||
if (QUALITY_LABELS[id]) return QUALITY_LABELS[id];
|
||||
return /^\d+$/.test(id) ? `${id} kbps` : id;
|
||||
}
|
||||
|
||||
export function formatLabel(id: string): string {
|
||||
return FORMAT_LABELS[id] || id;
|
||||
}
|
||||
|
||||
/** Relative time for a DownloadInfo timestamp (nanoseconds). */
|
||||
export function relativeTime(timestampNs: number): string {
|
||||
if (!timestampNs) return '';
|
||||
const diffMs = Date.now() - timestampNs / 1e6;
|
||||
const diff = Math.floor(diffMs / 1000);
|
||||
if (diff < 60) return '刚刚';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`;
|
||||
if (diff < 86400 * 30) return `${Math.floor(diff / 86400)} 天前`;
|
||||
return new Date(timestampNs / 1e6).toLocaleDateString();
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import { Component, Input, ViewChild, ElementRef, Output, EventEmitter } from '@angular/core';
|
||||
|
||||
interface Checkable {
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-master-checkbox',
|
||||
template: `
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="{{id}}-select-all" #masterCheckbox [(ngModel)]="selected" (change)="clicked()">
|
||||
<label class="form-check-label" for="{{id}}-select-all"></label>
|
||||
</div>
|
||||
`,
|
||||
standalone: false
|
||||
})
|
||||
export class MasterCheckboxComponent {
|
||||
@Input() id: string;
|
||||
@Input() list: Map<String, Checkable>;
|
||||
@Output() changed = new EventEmitter<number>();
|
||||
|
||||
@ViewChild('masterCheckbox') masterCheckbox: ElementRef;
|
||||
selected: boolean;
|
||||
|
||||
clicked() {
|
||||
this.list.forEach(item => item.checked = this.selected);
|
||||
this.selectionChanged();
|
||||
}
|
||||
|
||||
selectionChanged() {
|
||||
if (!this.masterCheckbox)
|
||||
return;
|
||||
let checked: number = 0;
|
||||
this.list.forEach(item => { if(item.checked) checked++ });
|
||||
this.selected = checked > 0 && checked == this.list.size;
|
||||
this.masterCheckbox.nativeElement.indeterminate = checked > 0 && checked < this.list.size;
|
||||
this.changed.emit(checked);
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-slave-checkbox',
|
||||
template: `
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="{{master.id}}-{{id}}-select" [(ngModel)]="checkable.checked" (change)="master.selectionChanged()">
|
||||
<label class="form-check-label" for="{{master.id}}-{{id}}-select"></label>
|
||||
</div>
|
||||
`,
|
||||
standalone: false
|
||||
})
|
||||
export class SlaveCheckboxComponent {
|
||||
@Input() id: string;
|
||||
@Input() master: MasterCheckboxComponent;
|
||||
@Input() checkable: Checkable;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { CookieService } from 'ngx-cookie-service';
|
||||
|
||||
const EXPIRES_DAYS = 3650;
|
||||
|
||||
/**
|
||||
* Thin wrapper over ngx-cookie-service for the app's persisted UI
|
||||
* preferences (quality, format, theme, active tab/folder/view, sort...).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PreferencesService {
|
||||
constructor(private cookieService: CookieService) {}
|
||||
|
||||
get(name: string, fallback = ''): string {
|
||||
return this.cookieService.check(name) ? this.cookieService.get(name) : fallback;
|
||||
}
|
||||
|
||||
set(name: string, value: string): void {
|
||||
this.cookieService.set(name, value, { expires: EXPIRES_DAYS });
|
||||
}
|
||||
|
||||
getBool(name: string, fallback = false): boolean {
|
||||
if (!this.cookieService.check(name)) return fallback;
|
||||
return this.cookieService.get(name) === 'true';
|
||||
}
|
||||
|
||||
setBool(name: string, value: boolean): void {
|
||||
this.cookieService.set(name, value ? 'true' : 'false', { expires: EXPIRES_DAYS });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
<aside class="sidebar" [class.open]="open">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3" fill="currentColor" stroke="none"/></svg>
|
||||
</span>
|
||||
<span class="brand-name">MeTube</span>
|
||||
<span class="brand-ver num">{{ metubeVersion || 'dev' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<div class="nav-label">状态</div>
|
||||
<button class="nav-item" [class.active]="status === 'downloading'" (click)="selectStatus('downloading')">
|
||||
<fa-icon [icon]="faDownload" class="nav-icon"></fa-icon>
|
||||
<span>正在下载</span>
|
||||
<span class="dot" *ngIf="statusCounts && statusCounts.downloading > 0"></span>
|
||||
<span class="nav-count num">{{ statusCounts ? statusCounts.downloading : 0 }}</span>
|
||||
</button>
|
||||
<button class="nav-item" [class.active]="status === 'queued'" (click)="selectStatus('queued')">
|
||||
<fa-icon [icon]="faClock" class="nav-icon"></fa-icon>
|
||||
<span>排队中</span>
|
||||
<span class="nav-count num">{{ statusCounts ? statusCounts.queued : 0 }}</span>
|
||||
</button>
|
||||
<button class="nav-item" [class.active]="status === 'completed'" (click)="selectStatus('completed')">
|
||||
<fa-icon [icon]="faCheckCircle" class="nav-icon"></fa-icon>
|
||||
<span>已完成</span>
|
||||
<span class="nav-count num">{{ statusCounts ? statusCounts.completed : 0 }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<div class="nav-label">文件夹</div>
|
||||
<button class="nav-item" [class.active]="selectedFolder === 'all'" (click)="selectFolder('all')">
|
||||
<fa-icon [icon]="faHome" class="nav-icon"></fa-icon>
|
||||
<span>全部</span>
|
||||
<span class="nav-count num">{{ allCount }}</span>
|
||||
</button>
|
||||
<button *ngFor="let folder of folders" class="nav-item" [class.active]="selectedFolder === folder.name" (click)="selectFolder(folder.name)">
|
||||
<fa-icon [icon]="faFolderOpen" class="nav-icon"></fa-icon>
|
||||
<span>{{ folderLabel(folder.name) }}</span>
|
||||
<span class="nav-count num">{{ folder.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-foot">
|
||||
<button class="ghost-btn" (click)="openAdvanced.emit()" title="高级选项">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
高级选项
|
||||
</button>
|
||||
<button class="ghost-btn" (click)="openActivity.emit()" title="活动日志">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
日志
|
||||
</button>
|
||||
<div ngbDropdown class="theme-dd" placement="top-start" container="body">
|
||||
<button ngbDropdownToggle class="ghost-btn icon-only" title="主题">
|
||||
<fa-icon *ngIf="activeTheme" [icon]="activeTheme.icon"></fa-icon>
|
||||
</button>
|
||||
<div ngbDropdownMenu class="dropdown-menu">
|
||||
<button *ngFor="let theme of themes" ngbDropdownItem [class.active]="activeTheme === theme" (click)="themeChanged.emit(theme)">
|
||||
<fa-icon [icon]="theme.icon"></fa-icon>
|
||||
<span class="ms-2">{{ theme.displayName }}</span>
|
||||
<fa-icon *ngIf="activeTheme === theme" [icon]="faCheck" class="ms-auto"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
:host
|
||||
display: contents
|
||||
|
||||
.sidebar
|
||||
background: var(--surface)
|
||||
border-right: 1px solid var(--border)
|
||||
display: flex
|
||||
flex-direction: column
|
||||
overflow-y: auto
|
||||
|
||||
.brand
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 10px
|
||||
padding: 16px 18px 14px
|
||||
border-bottom: 1px solid var(--border)
|
||||
flex: none
|
||||
|
||||
.brand-mark
|
||||
width: 28px
|
||||
height: 28px
|
||||
border-radius: 7px
|
||||
background: var(--fg)
|
||||
color: var(--surface)
|
||||
display: grid
|
||||
place-items: center
|
||||
flex: none
|
||||
|
||||
.brand-name
|
||||
font-family: var(--font-display)
|
||||
font-size: 15px
|
||||
font-weight: 600
|
||||
letter-spacing: -0.01em
|
||||
|
||||
.brand-ver
|
||||
font-family: var(--font-mono)
|
||||
font-size: 11px
|
||||
color: var(--muted)
|
||||
margin-left: auto
|
||||
|
||||
.nav-section
|
||||
padding: 14px 10px 4px
|
||||
|
||||
.nav-label
|
||||
font-size: 11px
|
||||
font-weight: 600
|
||||
letter-spacing: 0.06em
|
||||
color: var(--muted)
|
||||
padding: 0 8px 6px
|
||||
|
||||
.nav-item
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 9px
|
||||
width: 100%
|
||||
padding: 7px 8px
|
||||
border: none
|
||||
background: none
|
||||
border-radius: 6px
|
||||
font-size: 13.5px
|
||||
color: var(--fg)
|
||||
text-align: left
|
||||
position: relative
|
||||
transition: background 0.12s ease
|
||||
|
||||
&:hover
|
||||
background: var(--bg)
|
||||
|
||||
&.active
|
||||
background: var(--accent-tint)
|
||||
font-weight: 550
|
||||
|
||||
&::before
|
||||
content: ""
|
||||
position: absolute
|
||||
left: -10px
|
||||
top: 6px
|
||||
bottom: 6px
|
||||
width: 3px
|
||||
border-radius: 0 2px 2px 0
|
||||
background: var(--accent)
|
||||
|
||||
.nav-icon
|
||||
flex: none
|
||||
color: var(--muted)
|
||||
font-size: 13px
|
||||
|
||||
&.active .nav-icon
|
||||
color: var(--accent-strong)
|
||||
|
||||
.nav-count
|
||||
margin-left: auto
|
||||
font-family: var(--font-mono)
|
||||
font-size: 11.5px
|
||||
color: var(--muted)
|
||||
background: var(--bg)
|
||||
border: 1px solid var(--border)
|
||||
border-radius: 20px
|
||||
padding: 0 7px
|
||||
line-height: 18px
|
||||
|
||||
&.active .nav-count
|
||||
background: var(--surface)
|
||||
border-color: oklch(85% 0.05 145)
|
||||
color: var(--accent-strong)
|
||||
|
||||
.dot
|
||||
width: 7px
|
||||
height: 7px
|
||||
border-radius: 50%
|
||||
background: var(--accent)
|
||||
flex: none
|
||||
animation: pulse 1.6s ease-in-out infinite
|
||||
|
||||
@keyframes pulse
|
||||
50%
|
||||
opacity: 0.35
|
||||
|
||||
.sidebar-foot
|
||||
margin-top: auto
|
||||
border-top: 1px solid var(--border)
|
||||
padding: 10px
|
||||
display: flex
|
||||
gap: 6px
|
||||
flex-wrap: wrap
|
||||
|
||||
.theme-dd
|
||||
margin-left: auto
|
||||
|
||||
.ghost-btn
|
||||
display: inline-flex
|
||||
align-items: center
|
||||
gap: 7px
|
||||
padding: 7px 10px
|
||||
border: 1px solid var(--border)
|
||||
background: var(--surface)
|
||||
border-radius: 6px
|
||||
font-size: 12.5px
|
||||
color: var(--fg)
|
||||
transition: background 0.12s ease, border-color 0.12s ease
|
||||
|
||||
&:hover
|
||||
background: var(--bg)
|
||||
border-color: oklch(82% 0.01 240)
|
||||
|
||||
svg, fa-icon
|
||||
color: var(--muted)
|
||||
|
||||
&.icon-only
|
||||
padding: 7px 9px
|
||||
|
||||
@media (max-width: 900px)
|
||||
.sidebar
|
||||
position: fixed
|
||||
z-index: 45
|
||||
width: 260px
|
||||
height: 100%
|
||||
top: 0
|
||||
left: 0
|
||||
transform: translateX(-100%)
|
||||
transition: transform 0.2s ease
|
||||
box-shadow: 12px 0 40px oklch(30% 0.02 240 / 0.15)
|
||||
|
||||
&.open
|
||||
transform: translateX(0)
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { faCheck, faClock, faDownload, faFolderOpen, faHome } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCheckCircle } from '@fortawesome/free-regular-svg-icons';
|
||||
import { Theme } from './theme';
|
||||
|
||||
export interface FolderNav {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-sidebar',
|
||||
standalone: false,
|
||||
templateUrl: './sidebar.component.html',
|
||||
styleUrls: ['./sidebar.component.sass']
|
||||
})
|
||||
export class SidebarComponent {
|
||||
@Input() status: string;
|
||||
@Input() selectedFolder: string;
|
||||
@Input() statusCounts: { downloading: number; queued: number; completed: number };
|
||||
@Input() folders: FolderNav[] = [];
|
||||
@Input() allCount = 0;
|
||||
@Input() metubeVersion: string | null = null;
|
||||
@Input() themes: Theme[] = [];
|
||||
@Input() activeTheme: Theme | null = null;
|
||||
@Input() open = false;
|
||||
|
||||
@Output() statusChange = new EventEmitter<string>();
|
||||
@Output() folderChange = new EventEmitter<string>();
|
||||
@Output() openAdvanced = new EventEmitter<void>();
|
||||
@Output() openActivity = new EventEmitter<void>();
|
||||
@Output() themeChanged = new EventEmitter<Theme>();
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
faDownload = faDownload;
|
||||
faClock = faClock;
|
||||
faCheckCircle = faCheckCircle;
|
||||
faFolderOpen = faFolderOpen;
|
||||
faHome = faHome;
|
||||
faCheck = faCheck;
|
||||
|
||||
folderLabel(name: string): string {
|
||||
return name || '未分类';
|
||||
}
|
||||
|
||||
selectStatus(status: string): void {
|
||||
this.statusChange.emit(status);
|
||||
this.close.emit();
|
||||
}
|
||||
|
||||
selectFolder(folder: string): void {
|
||||
this.folderChange.emit(folder);
|
||||
this.close.emit();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { Component, Input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stats-strip',
|
||||
standalone: false,
|
||||
template: `
|
||||
<div class="stats">
|
||||
<div class="stat"><span class="live">● 下载中</span><strong class="num">{{ active }}</strong></div>
|
||||
<div class="stat">总速度 <strong class="num">{{ speed | speed }}</strong></div>
|
||||
<div class="stat">队列 <strong class="num">{{ queued }}</strong></div>
|
||||
<div class="stat">已入库存储 <strong class="num">{{ storage | fileSize }}</strong></div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host { display: block; flex: none; }
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.stat {
|
||||
padding: 8px 22px;
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.stat strong {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--fg);
|
||||
}
|
||||
.stat .live { color: var(--accent-strong); font-weight: 500; }
|
||||
@media (max-width: 900px) {
|
||||
.stats { flex-wrap: wrap; }
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class StatsStripComponent {
|
||||
@Input() active = 0;
|
||||
@Input() speed = 0;
|
||||
@Input() queued = 0;
|
||||
@Input() storage = 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
error?: boolean;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-blocking toast notifications; replaces the previous alert() calls.
|
||||
* ToastsComponent renders this service's list.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ToastService {
|
||||
toasts: Toast[] = [];
|
||||
private nextId = 1;
|
||||
private readonly okDuration = 3600;
|
||||
private readonly errorDuration = 6000;
|
||||
|
||||
show(message: string, opts: { error?: boolean; actionLabel?: string; onAction?: () => void } = {}): number {
|
||||
const toast: Toast = { id: this.nextId++, message, ...opts };
|
||||
this.toasts = [...this.toasts, toast];
|
||||
const ms = toast.error ? this.errorDuration : this.okDuration;
|
||||
setTimeout(() => this.dismiss(toast.id), ms);
|
||||
return toast.id;
|
||||
}
|
||||
|
||||
dismiss(id: number): void {
|
||||
this.toasts = this.toasts.filter(t => t.id !== id);
|
||||
}
|
||||
|
||||
runAction(toast: Toast): void {
|
||||
if (toast.onAction) toast.onAction();
|
||||
this.dismiss(toast.id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { Component } from '@angular/core';
|
||||
import { faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons';
|
||||
import { Toast, ToastService } from './toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-toasts',
|
||||
standalone: false,
|
||||
template: `
|
||||
<div class="toasts" aria-live="polite">
|
||||
<div *ngFor="let toast of toastService.toasts" class="toast" [class.err]="toast.error">
|
||||
<fa-icon [icon]="toast.error ? faTimesCircle : faCheckCircle" class="t-icon"></fa-icon>
|
||||
<span class="t-msg">{{ toast.message }}</span>
|
||||
<button *ngIf="toast.actionLabel" type="button" class="t-act" (click)="toastService.runAction(toast)">
|
||||
{{ toast.actionLabel }}
|
||||
</button>
|
||||
<button type="button" class="t-close" (click)="toastService.dismiss(toast.id)" aria-label="关闭">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host { display: block; }
|
||||
.toasts {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 60;
|
||||
}
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
padding: 11px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--fg);
|
||||
box-shadow: 0 8px 24px oklch(30% 0.02 240 / 0.14);
|
||||
min-width: 260px;
|
||||
max-width: min(420px, calc(100vw - 40px));
|
||||
animation: toast-in 0.25s ease;
|
||||
}
|
||||
.toast.err { border-left-color: var(--danger); }
|
||||
.t-icon { flex: none; color: var(--accent-strong); }
|
||||
.toast.err .t-icon { color: var(--danger); }
|
||||
.t-msg { flex: 1; }
|
||||
.t-act {
|
||||
flex: none;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--accent-strong);
|
||||
font-size: 12.5px;
|
||||
font-weight: 550;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.t-act:hover { background: var(--accent-tint); }
|
||||
.t-close {
|
||||
flex: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.t-close:hover { background: var(--bg); color: var(--fg); }
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ToastsComponent {
|
||||
faCheckCircle = faCheckCircle;
|
||||
faTimesCircle = faTimesCircle;
|
||||
|
||||
constructor(public toastService: ToastService) {}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>MeTube</title>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,26 @@
|
|||
/* You can add global styles to this file, and also import other style files */
|
||||
@use 'styles/_tokens' as *
|
||||
|
||||
.navbar
|
||||
background-color: var(--bs-dark) !important
|
||||
body
|
||||
font-family: var(--font-body)
|
||||
background: var(--bg)
|
||||
color: var(--fg)
|
||||
font-size: 14px
|
||||
line-height: 1.55
|
||||
-webkit-font-smoothing: antialiased
|
||||
|
||||
[data-bs-theme="dark"] &
|
||||
background-color: var(--bs-dark-bg-subtle) !important
|
||||
button
|
||||
font-family: inherit
|
||||
cursor: pointer
|
||||
|
||||
input, select
|
||||
font-family: inherit
|
||||
|
||||
:focus-visible
|
||||
outline: 2px solid var(--accent)
|
||||
outline-offset: 2px
|
||||
border-radius: 4px
|
||||
|
||||
.num
|
||||
font-family: var(--font-mono)
|
||||
font-variant-numeric: tabular-nums
|
||||
letter-spacing: 0
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
// MeTube design tokens — ported from design/metube-redesign.html
|
||||
// Applied as CSS custom properties; dark variants key off Bootstrap's
|
||||
// data-bs-theme attribute (set by theme.ts on <html>).
|
||||
|
||||
:root
|
||||
--bg: oklch(98% 0.005 250)
|
||||
--surface: oklch(100% 0 0)
|
||||
--fg: oklch(22% 0.02 240)
|
||||
--muted: oklch(50% 0.018 240)
|
||||
--border: oklch(90% 0.008 240)
|
||||
--accent: oklch(58% 0.16 145)
|
||||
--accent-strong: oklch(48% 0.14 145)
|
||||
--accent-tint: oklch(95% 0.03 145)
|
||||
--success: oklch(58% 0.16 145)
|
||||
--warn: oklch(70% 0.15 75)
|
||||
--danger: oklch(55% 0.19 25)
|
||||
--danger-tint: oklch(95% 0.025 25)
|
||||
--placeholder: oklch(94% 0.01 240)
|
||||
|
||||
--font-display: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif
|
||||
--font-body: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif
|
||||
--font-mono: ui-monospace, 'JetBrains Mono', 'IBM Plex Mono', Menlo, monospace
|
||||
|
||||
--radius: 8px
|
||||
--sidebar-w: 236px
|
||||
|
||||
[data-bs-theme="dark"]
|
||||
--bg: oklch(16% 0.012 250)
|
||||
--surface: oklch(20% 0.014 250)
|
||||
--fg: oklch(92% 0.01 240)
|
||||
--muted: oklch(68% 0.012 240)
|
||||
--border: oklch(30% 0.014 250)
|
||||
--accent: oklch(66% 0.15 145)
|
||||
--accent-strong: oklch(74% 0.13 145)
|
||||
--accent-tint: oklch(26% 0.05 145)
|
||||
--success: oklch(66% 0.15 145)
|
||||
--warn: oklch(74% 0.14 75)
|
||||
--danger: oklch(62% 0.17 25)
|
||||
--danger-tint: oklch(24% 0.03 25)
|
||||
--placeholder: oklch(26% 0.014 250)
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
|
||||
|
||||
import 'zone.js/dist/zone-testing';
|
||||
import { getTestBed } from '@angular/core/testing';
|
||||
import {
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting
|
||||
} from '@angular/platform-browser-dynamic/testing';
|
||||
|
||||
// First, initialize the Angular testing environment.
|
||||
getTestBed().initTestEnvironment(
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting()
|
||||
);
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"files": [
|
||||
"src/test.ts",
|
||||
"src/polyfills.ts"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
152
ui/tslint.json
152
ui/tslint.json
|
|
@ -1,152 +0,0 @@
|
|||
{
|
||||
"extends": "tslint:recommended",
|
||||
"rulesDirectory": [
|
||||
"codelyzer"
|
||||
],
|
||||
"rules": {
|
||||
"align": {
|
||||
"options": [
|
||||
"parameters",
|
||||
"statements"
|
||||
]
|
||||
},
|
||||
"array-type": false,
|
||||
"arrow-return-shorthand": true,
|
||||
"curly": true,
|
||||
"deprecation": {
|
||||
"severity": "warning"
|
||||
},
|
||||
"eofline": true,
|
||||
"import-blacklist": [
|
||||
true,
|
||||
"rxjs/Rx"
|
||||
],
|
||||
"import-spacing": true,
|
||||
"indent": {
|
||||
"options": [
|
||||
"spaces"
|
||||
]
|
||||
},
|
||||
"max-classes-per-file": false,
|
||||
"max-line-length": [
|
||||
true,
|
||||
140
|
||||
],
|
||||
"member-ordering": [
|
||||
true,
|
||||
{
|
||||
"order": [
|
||||
"static-field",
|
||||
"instance-field",
|
||||
"static-method",
|
||||
"instance-method"
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-console": [
|
||||
true,
|
||||
"debug",
|
||||
"info",
|
||||
"time",
|
||||
"timeEnd",
|
||||
"trace"
|
||||
],
|
||||
"no-empty": false,
|
||||
"no-inferrable-types": [
|
||||
true,
|
||||
"ignore-params"
|
||||
],
|
||||
"no-non-null-assertion": true,
|
||||
"no-redundant-jsdoc": true,
|
||||
"no-switch-case-fall-through": true,
|
||||
"no-var-requires": false,
|
||||
"object-literal-key-quotes": [
|
||||
true,
|
||||
"as-needed"
|
||||
],
|
||||
"quotemark": [
|
||||
true,
|
||||
"single"
|
||||
],
|
||||
"semicolon": {
|
||||
"options": [
|
||||
"always"
|
||||
]
|
||||
},
|
||||
"space-before-function-paren": {
|
||||
"options": {
|
||||
"anonymous": "never",
|
||||
"asyncArrow": "always",
|
||||
"constructor": "never",
|
||||
"method": "never",
|
||||
"named": "never"
|
||||
}
|
||||
},
|
||||
"typedef": [
|
||||
true,
|
||||
"call-signature"
|
||||
],
|
||||
"typedef-whitespace": {
|
||||
"options": [
|
||||
{
|
||||
"call-signature": "nospace",
|
||||
"index-signature": "nospace",
|
||||
"parameter": "nospace",
|
||||
"property-declaration": "nospace",
|
||||
"variable-declaration": "nospace"
|
||||
},
|
||||
{
|
||||
"call-signature": "onespace",
|
||||
"index-signature": "onespace",
|
||||
"parameter": "onespace",
|
||||
"property-declaration": "onespace",
|
||||
"variable-declaration": "onespace"
|
||||
}
|
||||
]
|
||||
},
|
||||
"variable-name": {
|
||||
"options": [
|
||||
"ban-keywords",
|
||||
"check-format",
|
||||
"allow-pascal-case"
|
||||
]
|
||||
},
|
||||
"whitespace": {
|
||||
"options": [
|
||||
"check-branch",
|
||||
"check-decl",
|
||||
"check-operator",
|
||||
"check-separator",
|
||||
"check-type",
|
||||
"check-typecast"
|
||||
]
|
||||
},
|
||||
"component-class-suffix": true,
|
||||
"contextual-lifecycle": true,
|
||||
"directive-class-suffix": true,
|
||||
"no-conflicting-lifecycle": true,
|
||||
"no-host-metadata-property": true,
|
||||
"no-input-rename": true,
|
||||
"no-inputs-metadata-property": true,
|
||||
"no-output-native": true,
|
||||
"no-output-on-prefix": true,
|
||||
"no-output-rename": true,
|
||||
"no-outputs-metadata-property": true,
|
||||
"template-banana-in-box": true,
|
||||
"template-no-negated-async": true,
|
||||
"use-lifecycle-interface": true,
|
||||
"use-pipe-transform-interface": true,
|
||||
"directive-selector": [
|
||||
true,
|
||||
"attribute",
|
||||
"app",
|
||||
"camelCase"
|
||||
],
|
||||
"component-selector": [
|
||||
true,
|
||||
"element",
|
||||
"app",
|
||||
"kebab-case"
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue