Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d9a99ce54 | ||
|
|
45dd4bdda7 | ||
|
|
dac391a890 | ||
|
|
4b33036e7c | ||
|
|
80dea4f307 | ||
|
|
20e17c0ccb | ||
|
|
7d302f71b7 | ||
|
|
f99240d8ca | ||
|
|
cc8cf96c19 | ||
|
|
86163958d6 | ||
|
|
12822a9e04 | ||
|
|
928eac4c89 | ||
|
|
b28be682fe | ||
|
|
7e97559615 | ||
|
|
721467ef69 | ||
|
|
47e2293de3 | ||
|
|
29bb914a95 | ||
|
|
5bfcf5d83a | ||
|
|
31fa554b87 | ||
|
|
6a0efba477 | ||
|
|
9f28f46839 | ||
|
|
14f1c97c0a | ||
|
|
fc56f514b3 | ||
|
|
358d722ce9 | ||
|
|
fe38dfbcca | ||
|
|
f98b060e2b | ||
|
|
bc9e34d0ae | ||
|
|
0cc450d1c9 | ||
|
|
688efb9ed6 | ||
|
|
ca4e559cc7 |
+1818
File diff suppressed because it is too large
Load Diff
@@ -26,8 +26,8 @@ The local gate is **ruff (`check` + `format --check`) + typecheck (3 tsc project
|
||||
keep commits logical and Conventional-Commit-styled.
|
||||
- **No AI attribution in commits.** No `Co-Authored-By` trailer, no "generated by" lines. (This file
|
||||
being AI-written is the intended, visible exception; commits are not.)
|
||||
- **Release: user-owned in timing, agent-run in execution.** The user decides *when* to ship; on their
|
||||
go-ahead *you* run `siftlode release` from the feature branch (merge to `main` → publish → deploy →
|
||||
- **Release: user-owned in timing, agent-run in execution.** The user decides _when_ to ship; on their
|
||||
go-ahead _you_ run `siftlode release` from the feature branch (merge to `main` → publish → deploy →
|
||||
verify `/api/version`). Never self-ship — bring work to a green, committed state and stop for the
|
||||
go-ahead first, then run it yourself.
|
||||
- **Public repo.** Keep ops/topology/secrets out — those live in a separate private repo. Nothing here
|
||||
@@ -41,7 +41,7 @@ how it gets built. Concretely:
|
||||
- **Plan before you build.** For anything non-trivial, present a short plan (sprint-level for larger
|
||||
work) and get a go-ahead before writing code.
|
||||
- **Offer choices, mark the optimal — and explain the trade-off.** When several solutions are viable,
|
||||
list them and mark the recommended one. This is the *one* place to spend extra words: give the *why*
|
||||
list them and mark the recommended one. This is the _one_ place to spend extra words: give the _why_
|
||||
behind each option and the weight of the decision. These decision points are where the product owner
|
||||
engages with the build and learns from it, so make them understandable — a reasoned menu, not a bare one.
|
||||
- **Mockups for design/UX decisions.** For any visual or interaction choice, show mockup options to
|
||||
@@ -59,31 +59,38 @@ how it gets built. Concretely:
|
||||
`downloads/`, `youtube/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
|
||||
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`,
|
||||
`security`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
|
||||
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → components; data
|
||||
via react-query (`qk` factory), per-account `localStorage`, and server prefs; API through `lib/api/*`
|
||||
slices over `core.req<T>`.
|
||||
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
|
||||
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
|
||||
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
|
||||
boundary is one-way and **lint-enforced**, not just described: a platform component may not import a
|
||||
feature component or a domain API slice (`no-restricted-imports` in `eslint.config.js`, with its own
|
||||
test). A new generic component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
|
||||
Data via react-query (`qk` factory), per-account `localStorage`, and server prefs; API through
|
||||
`lib/api/*` slices over `core.req<T>`.
|
||||
|
||||
**Not wanted** (reviewed and rejected as over-engineering for this size): microservices, event bus,
|
||||
CQRS, a repository/DI framework, a router library, a generic CRUD-router factory or base serializer.
|
||||
|
||||
## Canonical patterns — when you have a choice, pick THIS
|
||||
|
||||
| Area | Use | Not |
|
||||
|---|---|---|
|
||||
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
|
||||
| Request body | a Pydantic model | `payload: dict` |
|
||||
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
|
||||
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
||||
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
||||
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
|
||||
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`) | ad-hoc `{"unsubscribed"}`/`{"cleared": n}`/echo |
|
||||
| Transaction commit | the handler/job-wrapper commits; helpers `flush` | helpers that commit (only `quota` owns its session) |
|
||||
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
|
||||
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
||||
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
||||
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
||||
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
|
||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||
| Area | Use | Not |
|
||||
| ----------------------- | ------------------------------------------------------------ | ------------------------------------------------------ |
|
||||
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
|
||||
| Request body | a Pydantic model | `payload: dict` |
|
||||
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
|
||||
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
||||
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
||||
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
|
||||
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`) | ad-hoc `{"unsubscribed"}`/`{"cleared": n}`/echo |
|
||||
| Transaction commit | the handler/job-wrapper commits; helpers `flush` | helpers that commit (only `quota` owns its session) |
|
||||
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
|
||||
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
||||
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
||||
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
||||
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
|
||||
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
||||
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
|
||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||
|
||||
## House rules that bite (learned from real bugs)
|
||||
|
||||
@@ -100,6 +107,6 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
|
||||
---
|
||||
|
||||
*Run-it-locally and the full gate commands live in [`docs/development.md`](docs/development.md); backend
|
||||
_Run-it-locally and the full gate commands live in [`docs/development.md`](docs/development.md); backend
|
||||
code conventions in [`backend/app/README.md`](backend/app/README.md). This file stays the source of
|
||||
truth for the canonical-patterns table and the gate/workflow/collaboration rules above.*
|
||||
truth for the canonical-patterns table and the gate/workflow/collaboration rules above._
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -94,9 +94,18 @@ docker compose -f docker-compose.selfhost.yml pull # or: docker compose pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
Database migrations run automatically on startup. Your data (accounts, subscriptions, playlists, the
|
||||
video catalog) lives in a Postgres volume — back it up with `scripts/backup.sh` (or `backup.ps1` on
|
||||
Windows) and restore with `scripts/restore.sh`.
|
||||
That follows `latest`, which moves with every release. Set `IMAGE_TAG=0.80.0` in `.env` to pin a
|
||||
version and upgrade on your own schedule; released versions are listed in [`CHANGELOG.md`](CHANGELOG.md)
|
||||
and in the app's Release Notes. The registry keeps only the newest few of those, so a pin decides
|
||||
*when* you take the next hop rather than letting you stay put forever — see
|
||||
[docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||
|
||||
**Back up before upgrading.** Migrations run automatically on startup and are not undone by pulling
|
||||
the old image back. A backup is the database dump **plus your `.env`** — `TOKEN_ENCRYPTION_KEY` is
|
||||
what decrypts the Google, SMTP and Plex secrets stored in that database, so a dump restored without it
|
||||
is only half an instance. Use `scripts/backup.sh` (or `backup.ps1` on Windows) and `scripts/restore.sh`;
|
||||
the equivalent one-liners, the pinning recipe and the rollback steps are in
|
||||
[docs/self-hosting.md](docs/self-hosting.md#backups).
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -111,6 +120,12 @@ Windows) and restore with `scripts/restore.sh`.
|
||||
FastAPI + PostgreSQL (SQLAlchemy, Alembic) backend; React + Vite + Tailwind + TanStack Query
|
||||
frontend; packaged as a single Docker image with Docker Compose.
|
||||
|
||||
## License
|
||||
|
||||
[GNU AGPL-3.0](LICENSE). You can run, study, modify and share Siftlode; if you modify it and let other
|
||||
people use it over a network, they are entitled to your modified source. Copyright © 2026 Siftlode
|
||||
contributors.
|
||||
|
||||
## Note
|
||||
|
||||
This project is developed with AI assistance.
|
||||
|
||||
@@ -31,6 +31,7 @@ class AuditAction:
|
||||
# recorded because both are the admin acting ON someone's access, not merely reading a list.
|
||||
USER_ACTIVATE = "user_activate"
|
||||
USER_VERIFY_RESEND = "user_verify_resend"
|
||||
USER_SETPW_LINK = "user_setpw_link"
|
||||
INVITE_APPROVE = "invite_approve"
|
||||
INVITE_DENY = "invite_deny"
|
||||
INVITE_ADD = "invite_add"
|
||||
|
||||
@@ -54,6 +54,10 @@ class EditAborted(Exception):
|
||||
|
||||
# --- edit spec ------------------------------------------------------------------------------
|
||||
|
||||
# SEC-13: ceiling on a join's cut-list. An accurate join emits two filters per segment into a single
|
||||
# ffmpeg `filter_complex`, so "however many the caller sent" is an unbounded command line.
|
||||
_MAX_SEGMENTS = 100
|
||||
|
||||
|
||||
def normalize_edit_spec(spec: dict | None) -> dict:
|
||||
"""Canonicalize a raw edit spec: clamp/round trim, coerce crop to ints, drop empties.
|
||||
@@ -88,6 +92,12 @@ def normalize_edit_spec(spec: dict | None) -> dict:
|
||||
if en > st:
|
||||
norm.append({"start_s": round(st, 3), "end_s": round(en, 3)})
|
||||
norm.sort(key=lambda x: x["start_s"])
|
||||
# SEC-13: an accurate join builds one ffmpeg `filter_complex` with two filters per segment,
|
||||
# so an unbounded list becomes an unbounded command line and an ffmpeg that never finishes.
|
||||
# The cap is far above any real cut-list (the UI's own splitter produces a handful) and the
|
||||
# excess is DROPPED rather than raising: the surplus segments are the attack, the first
|
||||
# _MAX_SEGMENTS are a legitimate edit.
|
||||
del norm[_MAX_SEGMENTS:]
|
||||
if norm:
|
||||
out["segments"] = norm
|
||||
# A join always chooses a codec path: accurate=filter-concat re-encode,
|
||||
|
||||
@@ -8,9 +8,28 @@ explanation (and decides whether a retry makes sense). The raw text stays availa
|
||||
`_RETRIABLE_MARKERS` — kept here as the single source of truth for "what kind of failure is this".
|
||||
"""
|
||||
|
||||
# Two worker-authored strings that this module owns because both are coupled to `_RULES`.
|
||||
#
|
||||
# QUOTA_FULL_MESSAGE is raised by the worker and must classify back to `quota_exceeded`; its rule
|
||||
# below matches a fragment of it, and test_download_errors pins the pair, because a message and its
|
||||
# rule drifting apart is exactly how a category silently becomes unreachable.
|
||||
#
|
||||
# NETWORK_REDACTED_MESSAGE is the opposite direction: it REPLACES a message after classification, so
|
||||
# it deliberately has no rule. Nothing re-classifies a stored `job.error` today; if anything ever
|
||||
# does, it must carry the original code rather than re-deriving one from this text.
|
||||
#
|
||||
# Other worker-raised texts are still inline literals at their raise sites. The ffmpeg ones and
|
||||
# "produced no output file" have markers here; the urlguard refusal deliberately has none and
|
||||
# classifies as `unknown` — it is a refusal, not a diagnosis, and no category would say more.
|
||||
QUOTA_FULL_MESSAGE = "Your download storage is full — free up space, then try again."
|
||||
NETWORK_REDACTED_MESSAGE = "The server could not reach that address."
|
||||
|
||||
# (marker, category) — first match wins, so order most-specific → most-general. Matching is
|
||||
# case-insensitive on a substring, against the raw yt-dlp/ffmpeg message.
|
||||
_RULES: tuple[tuple[str, str], ...] = (
|
||||
# Our own refusal: the worker found the user already at their storage cap (SEC-13). Placed
|
||||
# first — it is our wording, so it can never be a false match on an upstream message.
|
||||
("download storage is full", "quota_exceeded"),
|
||||
# Login / age / members / bot-check — the user must be signed in (we deliberately are not).
|
||||
("sign in to confirm your age", "login_required"),
|
||||
("confirm your age", "login_required"),
|
||||
@@ -91,3 +110,20 @@ def classify(msg: str | None) -> str:
|
||||
if marker in low:
|
||||
return category
|
||||
return "unknown"
|
||||
|
||||
|
||||
def user_error_text(source_kind: str, msg: str, code: str) -> str:
|
||||
"""The failure text to STORE on the job, given the raw message and its category.
|
||||
|
||||
SEC-12: for a user-supplied URL, a transport-level failure text distinguishes "connection
|
||||
refused" from "HTTP Error 401" from "timed out" per host:port — which is a port scanner with
|
||||
extra steps, since the raw text is shown to the user behind the details disclosure. `urlguard`
|
||||
stops the fetch aiming at our own network; this stops the remaining readout.
|
||||
|
||||
Only the network class, and only for ``url`` jobs: a YouTube job has no user-chosen host, and
|
||||
every other class (login_required, geo_blocked, drm, …) is real, useful diagnosis. A separate
|
||||
function rather than an inline branch in the worker so it can be pinned by a test — the raw
|
||||
text leaking back is a security regression that would otherwise pass a green suite."""
|
||||
if source_kind == "url" and code == "network":
|
||||
return NETWORK_REDACTED_MESSAGE
|
||||
return msg
|
||||
|
||||
@@ -116,6 +116,7 @@ def build_ydl_opts(
|
||||
progress_hook,
|
||||
extra: dict | None = None,
|
||||
cookiefile: str | None = None,
|
||||
max_bytes: int | None = None,
|
||||
) -> dict:
|
||||
"""Assemble yt-dlp options for a single download to `outtmpl` (a full path template).
|
||||
|
||||
@@ -125,12 +126,20 @@ def build_ydl_opts(
|
||||
|
||||
`cookiefile` is the acting user's per-host cookies.txt path (S6), attached ONLY by the worker
|
||||
after the host-scope + private-asset gate in `downloads.credentials`. It is set AFTER the `extra`
|
||||
merge so a source adapter can never clobber it."""
|
||||
merge so a source adapter can never clobber it.
|
||||
|
||||
`max_bytes` (SEC-13) is the caller's remaining storage budget; yt-dlp refuses a format larger
|
||||
than it rather than filling the disk and only then being caught by the quota accounting."""
|
||||
s = normalize(spec)
|
||||
h = s["max_height"]
|
||||
opts: dict = {
|
||||
"outtmpl": outtmpl,
|
||||
"noplaylist": True,
|
||||
# SEC-13: `noplaylist` only demotes a video-inside-a-playlist URL to the single video. A
|
||||
# URL that is PURELY a playlist or channel has no such video to fall back to, so it still
|
||||
# expands into every entry — one enqueue, an unbounded number of downloads. One job means
|
||||
# one item; a user who wants the whole list enqueues it item by item, under their quota.
|
||||
"playlist_items": "1",
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"noprogress": True,
|
||||
@@ -154,6 +163,16 @@ def build_ydl_opts(
|
||||
if extra:
|
||||
opts.update(extra)
|
||||
|
||||
# SEC-13: hard ceiling on a single download. AFTER the adapter merge, for the same reason
|
||||
# `cookiefile` is — a source adapter must not be able to raise a limit the quota layer set.
|
||||
#
|
||||
# A non-positive budget is left unset because 0 is not an expressible yt-dlp filter, NOT because
|
||||
# nothing needs to happen: the worker refuses that job outright before extraction (`_OwnerRefused`
|
||||
# → `quota_exceeded`). Enqueue cannot make that call — it ran while the user was still under the
|
||||
# cap. Delete the worker's refusal and this branch silently uncaps the one user already over it.
|
||||
if max_bytes and max_bytes > 0:
|
||||
opts["max_filesize"] = max_bytes
|
||||
|
||||
# The acting user's per-host cookie (S6). AFTER the adapter merge on purpose: an adapter must not
|
||||
# be able to override the authenticated session the worker resolved for this specific user.
|
||||
if cookiefile:
|
||||
|
||||
@@ -15,7 +15,8 @@ from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads import adapters, credentials, formats, mediakind, probe, service, storage
|
||||
from app.downloads import adapters, credentials, formats, mediakind, probe, quota, service, storage
|
||||
from app.downloads import urlguard
|
||||
from app.downloads.jobdb import (
|
||||
_edit_staging_dir,
|
||||
_job_status,
|
||||
@@ -36,6 +37,18 @@ class _Aborted(Exception):
|
||||
"""Raised inside the progress hook when the job was paused/canceled mid-download."""
|
||||
|
||||
|
||||
class _OwnerRefused(Exception):
|
||||
"""A refusal that belongs to THIS job's owner, not to the source.
|
||||
|
||||
MediaAssets are shared: two users asking for the same video and format get one row (see
|
||||
`service.get_or_create_asset`), and the generic failure handler writes the message onto it —
|
||||
correctly, because an upstream failure really is everyone's. A per-user refusal is not. Written
|
||||
to the shared row it would fail a second user's queued job for the same video with the first
|
||||
user's private reason (`worker._process_job` reads `asset.error` verbatim), telling someone with
|
||||
an empty library to free up space. So this is caught separately: the job fails, the asset goes
|
||||
back to `pending` for whoever else is holding it."""
|
||||
|
||||
|
||||
# Both progress sources call back FAR faster than the database should be touched: yt-dlp fires its
|
||||
# hook per chunk, and ffmpeg's `-progress pipe:1` emits a block of ~12 lines every ~0.5s while
|
||||
# run_ffmpeg calls its callbacks once per LINE. Every callback here is a round-trip (`_job_status`
|
||||
@@ -305,13 +318,17 @@ def _media_info(info: dict) -> dict:
|
||||
return {**info, **chosen}
|
||||
|
||||
|
||||
def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter, cookiefile=None):
|
||||
def _extract_with_fallback(
|
||||
job_id, asset_id, url, spec, staging, adapter, cookiefile=None, max_bytes=None
|
||||
):
|
||||
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
|
||||
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
|
||||
error); other sources make a single attempt. Returns the info dict of the successful attempt.
|
||||
|
||||
`cookiefile` (S6) is the acting user's per-host cookies.txt, already gated by
|
||||
`credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated."""
|
||||
`credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated.
|
||||
|
||||
`max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt."""
|
||||
client_sets = adapter.client_sets()
|
||||
outtmpl = str(staging / "%(id)s.%(ext)s")
|
||||
last_exc: Exception | None = None
|
||||
@@ -322,6 +339,7 @@ def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter, cookie
|
||||
_make_progress_hook(job_id, asset_id),
|
||||
extra=adapter.ydl_opts_extra(clients),
|
||||
cookiefile=cookiefile,
|
||||
max_bytes=max_bytes,
|
||||
)
|
||||
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
||||
_touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow /
|
||||
@@ -415,8 +433,17 @@ def _download(
|
||||
_set_job(job_id, phase="downloading", progress=0)
|
||||
adapter = adapters.resolve_adapter(source_kind, source_ref)
|
||||
url = service.source_url(source_kind, source_ref)
|
||||
# SEC-12, second half: the enqueue guard ran when the job was created, which may have been
|
||||
# hours ago and against a different DNS answer. This is where the socket actually gets
|
||||
# opened, so the check is repeated here and the job fails rather than fetching.
|
||||
if source_kind == "url":
|
||||
try:
|
||||
urlguard.check_url(url)
|
||||
except urlguard.BlockedUrl as e:
|
||||
raise RuntimeError(f"Refusing to download from this address ({e.reason}).") from e
|
||||
# S6: attach the acting user's per-host cookie ONLY when it's host-scoped to this URL AND
|
||||
# this asset is that user's private, salted identity (the double gate lives in credentials).
|
||||
remaining_bytes: int | None = None
|
||||
with SessionLocal() as cdb:
|
||||
asset_sig = cdb.execute(
|
||||
select(MediaAsset.format_sig).where(MediaAsset.id == asset_id)
|
||||
@@ -425,6 +452,19 @@ def _download(
|
||||
cookiefile = credentials.resolve_cookiefile(
|
||||
cdb, user_id, url, asset_sig, spec, staging
|
||||
)
|
||||
# SEC-13: what this user has left. `check_enqueue` only compared the footprint as it
|
||||
# stood when the job was QUEUED — a download's size is unknown until it finishes, so a
|
||||
# single job could previously blow through the cap by any amount. Handing the remainder
|
||||
# to yt-dlp as `max_filesize` makes the format selector refuse an oversized file up
|
||||
# front instead.
|
||||
remaining_bytes = quota.remaining_bytes(cdb, user_id)
|
||||
# …and zero is a REFUSAL, not "no limit". Earlier jobs of this user may have filled the cap
|
||||
# while this one sat in the queue, and 0 is not an expressible yt-dlp `max_filesize`, so
|
||||
# passing it down would leave the ceiling unset — the one user already at their cap would be
|
||||
# the only one downloading unbounded. Fail the job here instead. Not retryable until they
|
||||
# free space, which is what the `quota_exceeded` category tells them.
|
||||
if remaining_bytes == 0:
|
||||
raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE)
|
||||
info = _media_info(
|
||||
_extract_with_fallback(
|
||||
job_id,
|
||||
@@ -434,6 +474,7 @@ def _download(
|
||||
staging,
|
||||
adapter,
|
||||
cookiefile=str(cookiefile) if cookiefile else None,
|
||||
max_bytes=remaining_bytes,
|
||||
)
|
||||
)
|
||||
# Re-resolve now that yt-dlp's info reveals the true source (a bare/redirected url only
|
||||
@@ -525,8 +566,31 @@ def _download(
|
||||
# job's status (the route set it to paused/canceled). ref_count is adjusted by the route.
|
||||
_reset_asset_pending(asset_id)
|
||||
log.info("job %s aborted (pause/cancel)", job_id)
|
||||
except _OwnerRefused as exc:
|
||||
# Fail this job only. The asset is never marked `error`; it goes back to `pending` (below),
|
||||
# so another user's job for the same video is claimed and downloaded normally instead of
|
||||
# inheriting a reason that is about someone else's account.
|
||||
msg = str(exc)[:500]
|
||||
with SessionLocal() as db:
|
||||
service.finalize_if_running(
|
||||
db,
|
||||
job_id,
|
||||
status=JobStatus.ERROR,
|
||||
error=msg,
|
||||
error_code=dlerrors.classify(msg),
|
||||
phase=None,
|
||||
)
|
||||
db.commit()
|
||||
# Released AFTER the job is terminal: the moment the asset is `pending` another job can claim
|
||||
# it, and this one must already be out of the way. (The staging tree is still torn down in
|
||||
# `finally` below — the same narrow window the pause/cancel path above has always had.)
|
||||
_reset_asset_pending(asset_id)
|
||||
log.info("job %s refused for its owner: %s", job_id, msg)
|
||||
except Exception as exc: # noqa: BLE001 — surface any failure to the user, keep worker alive
|
||||
msg = str(exc)[:500]
|
||||
code = dlerrors.classify(msg)
|
||||
# SEC-12: redact a transport-level readout for user-supplied URLs (see user_error_text).
|
||||
msg = dlerrors.user_error_text(source_kind, msg, code)
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
@@ -539,7 +603,7 @@ def _download(
|
||||
job_id,
|
||||
status=JobStatus.ERROR,
|
||||
error=msg,
|
||||
error_code=dlerrors.classify(msg),
|
||||
error_code=code,
|
||||
phase=None,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
@@ -7,6 +7,13 @@ Limits come from a per-user DownloadQuota row if the admin set one, else the sys
|
||||
|
||||
Enforcement split: max_jobs + max_bytes are checked at enqueue (they bound holdings); per-user
|
||||
max_concurrent is enforced by the worker at claim time (it bounds simultaneous downloads).
|
||||
|
||||
DOWNLOAD jobs additionally consult `remaining_bytes` in the worker, because enqueue can only compare
|
||||
the footprint as it stood when the job was created and earlier jobs may have filled the cap since.
|
||||
That is a per-job ceiling, not an aggregate ledger: concurrent jobs of one user each read the same
|
||||
remainder (in-flight bytes are not `ready`, so `footprint` cannot see them), so a wave of
|
||||
max_concurrent downloads can still land over the cap — the deliberate trade, since a download's size
|
||||
is unknown until it finishes. Edit jobs do not consult it at all (tracked as SEC-19).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -122,6 +129,21 @@ def check_enqueue(db: Session, user_id: int) -> None:
|
||||
raise QuotaExceeded("max_bytes", lim.max_bytes, used)
|
||||
|
||||
|
||||
def remaining_bytes(db: Session, user_id: int) -> int | None:
|
||||
"""Storage this user may still spend on ONE download: ``None`` for an unlimited user, else the
|
||||
cap minus the current footprint, floored at 0.
|
||||
|
||||
Zero is a real answer, not "no limit" — it means the cap is already met and the caller must
|
||||
REFUSE rather than proceed unbounded. `check_enqueue` cannot make that refusal on the caller's
|
||||
behalf: it ran when the job was queued, while the user was still under the cap, and the jobs
|
||||
that filled it have landed since. Keeping the arithmetic here (rather than inline in the worker)
|
||||
is what keeps every max_bytes decision in this module."""
|
||||
lim = resolve(db, user_id)
|
||||
if lim.unlimited:
|
||||
return None
|
||||
return max(0, lim.max_bytes - footprint(db, user_id))
|
||||
|
||||
|
||||
def check_upload(db: Session, user_id: int, add_bytes: int) -> None:
|
||||
"""Like check_enqueue, but for an upload whose size is KNOWN at ingest: also reject when adding
|
||||
this file would push the footprint over max_bytes. A download can't pre-check its size (unknown
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Refuse a user-supplied download URL that points back into our own network (SEC-12).
|
||||
|
||||
The Download Center deliberately accepts an arbitrary ``http(s)`` URL — that is the feature — and
|
||||
hands it to yt-dlp running on the WORKER. Without a guard, any approved user can make the server
|
||||
fetch anything the server can reach: other containers on the compose network, the LAN, a cloud
|
||||
metadata endpoint. The failure text then comes back to them in ``job.error``, so "connection
|
||||
refused" vs "HTTP Error 401" vs "timed out" turns the download box into an internal port scanner.
|
||||
|
||||
What this closes: a URL whose host resolves to a loopback / private / link-local / reserved address
|
||||
is refused, at enqueue AND again in the worker before extraction. Both, because DNS can answer
|
||||
differently between the two moments, and the worker is where the socket is actually opened.
|
||||
|
||||
What it does NOT close, stated plainly rather than implied, because both need the same fix — gating
|
||||
the connection itself with a custom socket factory inside yt-dlp, a much larger change than this:
|
||||
|
||||
* DNS rebinding: a host that passes the worker's check and then answers yt-dlp's own resolution
|
||||
with an internal address.
|
||||
* HTTP redirects: a public host that answers 3xx with an internal ``Location``. yt-dlp follows it,
|
||||
and only the URL the user TYPED was ever judged here. An attacker-controlled host or any public
|
||||
open redirect reaches it, so this is the cheaper of the two. It does NOT usually yield the
|
||||
response body — a non-media body fails the mp4 remux and stores nothing — but it does hand back
|
||||
the readout above: the redaction in ``errors.user_error_text`` fires only for the ``network``
|
||||
class, so a dead internal port (refused → ``network`` → redacted) is distinguishable from a live
|
||||
one (an HTTP answer that is not media → ``unsupported_host``/``format_unavailable``, raw text
|
||||
kept). Tracked as SEC-17.
|
||||
|
||||
The two checks here stop the straightforward case (typing ``http://127.0.0.1:6379`` or an internal
|
||||
hostname into the box), which is the reachable one.
|
||||
|
||||
Not used for YouTube jobs: those resolve to a fixed public host from an 11-char id, so there is no
|
||||
user-controlled destination to guard.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Reason codes travel to the client inside the structured 422 detail, so they are part of the API
|
||||
# vocabulary (frontend `localizeDetail`) — keep them stable.
|
||||
REASON_SCHEME = "scheme" # not http(s)
|
||||
REASON_NO_HOST = "no_host" # URL carries no hostname
|
||||
REASON_MALFORMED = "malformed" # unparseable authority or port; urlparse raises late, so guard it
|
||||
REASON_UNRESOLVABLE = "unresolvable" # DNS said nothing (honest: not the same as "blocked")
|
||||
REASON_PRIVATE = "private" # resolves into a network the server must not fetch from
|
||||
|
||||
|
||||
class BlockedUrl(Exception):
|
||||
"""A user-supplied URL that must not be fetched server-side. ``reason`` is one of the
|
||||
REASON_* codes above."""
|
||||
|
||||
def __init__(self, reason: str):
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
|
||||
|
||||
def _is_forbidden(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
"""Every address family we must not open a connection to. Checked explicitly rather than
|
||||
leaning on ``is_private`` alone, whose coverage differs between IPv4 and IPv6."""
|
||||
# ::ffff:127.0.0.1 is loopback wearing an IPv6 hat — unwrap before judging it.
|
||||
mapped = getattr(ip, "ipv4_mapped", None)
|
||||
if mapped is not None:
|
||||
ip = mapped
|
||||
return bool(
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_multicast
|
||||
or ip.is_reserved
|
||||
or ip.is_unspecified
|
||||
)
|
||||
|
||||
|
||||
def _resolve(host: str, port: int | None) -> list[str]:
|
||||
"""Every address ``host`` resolves to. Raises BlockedUrl(REASON_UNRESOLVABLE) when DNS has no
|
||||
answer — reported as its own outcome, because "we could not look that up" and "we refuse to
|
||||
fetch that" are different things to tell a user."""
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
||||
except socket.gaierror as exc:
|
||||
raise BlockedUrl(REASON_UNRESOLVABLE) from exc
|
||||
return [info[4][0] for info in infos]
|
||||
|
||||
|
||||
def check_url(raw: str) -> None:
|
||||
"""Raise BlockedUrl unless ``raw`` is an http(s) URL whose host resolves only to addresses the
|
||||
server is allowed to fetch from. A host with even ONE forbidden address is refused: a
|
||||
round-robin answer must not become a coin flip."""
|
||||
# `urlparse` itself raises ValueError on a malformed authority ("http://[::1/x" — an unclosed
|
||||
# bracket), and `.port` raises on ":99999" / ":abc" when READ rather than at parse time. Both
|
||||
# escape the caller's `except BlockedUrl` as a 500 unless caught here, which is the opposite of
|
||||
# what this module exists to do: turn a bad URL into a structured, localizable refusal.
|
||||
try:
|
||||
u = urlparse((raw or "").strip())
|
||||
port = u.port
|
||||
except ValueError as exc:
|
||||
raise BlockedUrl(REASON_MALFORMED) from exc
|
||||
if u.scheme not in ("http", "https"):
|
||||
raise BlockedUrl(REASON_SCHEME)
|
||||
host = u.hostname
|
||||
if not host:
|
||||
raise BlockedUrl(REASON_NO_HOST)
|
||||
for addr in _resolve(host, port):
|
||||
try:
|
||||
ip = ipaddress.ip_address(addr)
|
||||
except ValueError: # getaddrinfo returned something unparseable; treat as unusable
|
||||
raise BlockedUrl(REASON_PRIVATE) from None
|
||||
if _is_forbidden(ip):
|
||||
raise BlockedUrl(REASON_PRIVATE)
|
||||
@@ -32,7 +32,12 @@ from app.models import (
|
||||
User,
|
||||
)
|
||||
from app.routes._common import get_or_404
|
||||
from app.routes.auth import APPROVAL_VERIFY_TTL, issue_verification_link, send_verification_link
|
||||
from app.routes.auth import (
|
||||
APPROVAL_VERIFY_TTL,
|
||||
issue_set_password_link,
|
||||
issue_verification_link,
|
||||
send_verification_link,
|
||||
)
|
||||
from app.utils import canonical_email, iso, valid_email
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
@@ -168,9 +173,7 @@ def _activate_user_by_email(db: Session, email: str) -> None:
|
||||
|
||||
|
||||
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
|
||||
inv = db.get(Invite, invite_id)
|
||||
if inv is None:
|
||||
raise HTTPException(status_code=404, detail="No such invite")
|
||||
inv = get_or_404(db, Invite, invite_id, detail="No such invite")
|
||||
# Idempotency guard: only a still-pending invite can be decided. Without this a repeat/stale
|
||||
# approve or deny re-runs its side effects — re-activating a user, or (now that deny emails)
|
||||
# sending a duplicate decision mail, even flipping an already-approved invite to "denied" and
|
||||
@@ -361,9 +364,7 @@ def delete_invite(
|
||||
falls back to the env ALLOWED_EMAILS / ADMIN_EMAILS bootstrap lists once no invite exists, so for
|
||||
an address on those the deletion leaves access intact (and an ADMIN_EMAILS address is still
|
||||
promoted to admin on its next Google sign-in). The admin has to hear that, not guess it."""
|
||||
inv = db.get(Invite, invite_id)
|
||||
if inv is None:
|
||||
raise HTTPException(status_code=404, detail="No such invite")
|
||||
inv = get_or_404(db, Invite, invite_id, detail="No such invite")
|
||||
if _accounts_for(db, [inv.email]):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
@@ -397,7 +398,15 @@ def _serialize_user(u: User) -> dict:
|
||||
"email_verified": u.email_verified,
|
||||
"is_demo": u.is_demo,
|
||||
"has_password": u.password_hash is not None,
|
||||
# Distinguishes "this account never had a password" (Google-only) from "SEC-10 cleared it" —
|
||||
# the same distinction _send_reset_if_eligible needs, and the one an admin has to see before
|
||||
# deciding what kind of help someone needs.
|
||||
"password_revoked_at": iso(u.password_revoked_at),
|
||||
"has_google": u.google_sub is not None,
|
||||
# Only when it DIFFERS from the account address (E9.5 S3): "connected" is not the same
|
||||
# statement as "connected as someone else", and the second one is the one that explains a
|
||||
# confusing sign-in.
|
||||
"google_email": u.google_email if u.google_email != u.email else None,
|
||||
"created_at": iso(u.created_at),
|
||||
}
|
||||
|
||||
@@ -612,6 +621,63 @@ def list_pending_access(_: User = Depends(admin_user), db: Session = Depends(get
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/access")
|
||||
def user_access(
|
||||
user_id: int,
|
||||
_: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Everything the account panel shows about why this person can or cannot get in (E12 S5).
|
||||
|
||||
Its own endpoint rather than extra columns on /users: `is_allowed` and `can_activate` each run a
|
||||
query, and the panel opens for ONE user at a time — folding them into the list would pay that cost
|
||||
per row on every load to answer a question nobody asked yet."""
|
||||
target = get_or_404(db, User, user_id, detail="No such user")
|
||||
return _serialize_user(target) | {
|
||||
"is_allowed": is_allowed(db, target.email),
|
||||
"can_activate": can_activate(db, target),
|
||||
"email_configured": email_mod.email_enabled(db),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/set-password-link")
|
||||
def create_set_password_link(
|
||||
user_id: int,
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Mint a one-time link that lets this account set a password, with no email involved (E12 S5).
|
||||
|
||||
The answer to SEC-15, SEC-16 and "no SMTP and someone forgot their password" at once. The admin
|
||||
hands the link over by whatever channel they trust; opening it sets a password and signs out
|
||||
whoever held the old one.
|
||||
|
||||
Deliberately NOT gated on the account's state. It is useful for a pre-empted address, for a
|
||||
revoked password, for a Google-only account that wants one, and for a plain forgotten password on
|
||||
an instance with no mail — narrowing it to one of those would only make the admin's real case the
|
||||
one it refuses. The demo account is the single exception: it is shared, so handing anyone a way to
|
||||
change its password is handing them everyone else's session."""
|
||||
target = get_or_404(db, User, user_id, detail="No such user")
|
||||
if target.is_demo:
|
||||
raise HTTPException(
|
||||
status_code=400, detail={"code": "setpw", "reason": "demo", "email": target.email}
|
||||
)
|
||||
url, expires_at = issue_set_password_link(db, target)
|
||||
# The URL itself is NOT recorded: the audit log is read by humans and kept long, and a live
|
||||
# credential-setting link is the last thing that belongs in it. That the link was issued, by whom,
|
||||
# for whom, is the auditable fact.
|
||||
audit.record(
|
||||
db,
|
||||
admin.id,
|
||||
AuditAction.USER_SETPW_LINK,
|
||||
target_type="user",
|
||||
target_id=target.id,
|
||||
summary=target.email,
|
||||
)
|
||||
db.commit()
|
||||
return {"ok": True, "url": url, "expires_at": iso(expires_at), "email": target.email}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/activate")
|
||||
def activate_user(
|
||||
user_id: int,
|
||||
|
||||
@@ -52,6 +52,16 @@ VERIFY_TTL = timedelta(hours=24)
|
||||
# in and sets no password.
|
||||
APPROVAL_VERIFY_TTL = timedelta(days=7)
|
||||
RESET_TTL = timedelta(hours=1)
|
||||
# An admin-issued set-password link (E12 S5) travels out of band — the admin has to actually reach the
|
||||
# person and hand it over — so the one-hour reset window is far too short. A day matches VERIFY_TTL and
|
||||
# the affordance the admin was shown.
|
||||
ADMIN_SETPW_TTL = timedelta(hours=24)
|
||||
# Both kinds set a password from a link, and the SIGNED-OUT card consumes them through the same
|
||||
# `#reset=` entry, so the user-facing flow is one flow. They stay separate kinds for one reason: a
|
||||
# `reset` token proves control of the MAILBOX (it was mailed there) and therefore confirms the address;
|
||||
# an admin-issued one proves nothing about the mailbox — the admin could hand it to anyone — so it must
|
||||
# not silently satisfy the email gate. See password_reset_confirm.
|
||||
_PASSWORD_TOKEN_KINDS = ("reset", "setpw")
|
||||
|
||||
_DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
|
||||
|
||||
@@ -773,6 +783,35 @@ def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
|
||||
return db.get(User, row.user_id)
|
||||
|
||||
|
||||
def _consume_password_token(db: Session, raw: str | None) -> tuple[User, str] | None:
|
||||
"""Spend a password-setting token of either kind; returns (user, kind). The kind is returned, not
|
||||
swallowed, because the caller has to treat them differently — only a mailed reset attests the
|
||||
address."""
|
||||
for kind in _PASSWORD_TOKEN_KINDS:
|
||||
user = _consume_token(db, raw, kind)
|
||||
if user is not None:
|
||||
return user, kind
|
||||
return None
|
||||
|
||||
|
||||
def issue_set_password_link(db: Session, user: User) -> tuple[str, datetime]:
|
||||
"""An admin-issued, single-use link that lets `user` set a password without any email (E12 S5).
|
||||
|
||||
One mechanism answers three things: SEC-15 (on a no-SMTP instance every registration is
|
||||
auto-verified, so a pre-empted address cannot be recovered by mail), SEC-16 (a pre-empted address
|
||||
stays takeable until its owner resets, and password-reset needs working mail), and the everyday
|
||||
"no SMTP configured and someone forgot their password".
|
||||
|
||||
Issuing does NOT revoke the current password: an admin misclick must not lock anyone out, and the
|
||||
account may be perfectly healthy. USING the link replaces the hash and bumps the session epoch,
|
||||
which is what evicts a squatter — see password_reset_confirm."""
|
||||
expires_at = datetime.now(timezone.utc) + ADMIN_SETPW_TTL
|
||||
raw = _issue_token(db, user, "setpw", ADMIN_SETPW_TTL)
|
||||
# The same `#reset=` fragment the mailed link uses: the signed-out card already knows how to
|
||||
# consume it, and a second entry shape would be a second thing to keep correct.
|
||||
return f"{_app_base()}/#reset={raw}", expires_at
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
def register(
|
||||
body: EmailPasswordIn,
|
||||
@@ -1105,7 +1144,7 @@ def password_reset_check(body: TokenIn, request: Request, db: Session = Depends(
|
||||
"""
|
||||
if not _reset_check_limiter.allow(_client_ip(request)):
|
||||
return {"ok": True}
|
||||
return {"ok": _peek_token(db, body.token, "reset") is not None}
|
||||
return {"ok": any(_peek_token(db, body.token, k) is not None for k in _PASSWORD_TOKEN_KINDS)}
|
||||
|
||||
|
||||
@router.post("/password-reset/confirm")
|
||||
@@ -1118,16 +1157,23 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
|
||||
status_code=400,
|
||||
detail={"code": "reset", "reason": "weak_password", "min": PASSWORD_MIN_LEN},
|
||||
)
|
||||
user = _consume_token(db, token, "reset")
|
||||
if user is None:
|
||||
spent = _consume_password_token(db, token)
|
||||
if spent is None:
|
||||
raise HTTPException(status_code=400, detail={"code": "reset", "reason": "invalid"})
|
||||
user, kind = spent
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.password_revoked_at = None # the account has a password again — SEC-10's trace is spent
|
||||
# A valid reset link proves control of the mailbox, so completing a reset also verifies the
|
||||
# email — this rescues an account that registered but never clicked the verification link (it
|
||||
# can then sign in). The separate admin-approval gate (is_active) is deliberately NOT touched
|
||||
# here: a reset proves email ownership, not that an admin approved the account.
|
||||
user.email_verified = True
|
||||
#
|
||||
# An admin-issued `setpw` link proves NOTHING about the mailbox — the admin hands it over by
|
||||
# whatever means they choose — so it must not satisfy the email gate. Claiming otherwise would let
|
||||
# an admin confirm an address nobody ever proved, which is the same false attestation SEC-15 is
|
||||
# about. The person still confirms their address the normal way.
|
||||
if kind == "reset":
|
||||
user.email_verified = True
|
||||
bump_session_epoch(
|
||||
user
|
||||
) # SA4: a reset (often a compromise response) kills all existing sessions.
|
||||
@@ -1135,7 +1181,7 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
|
||||
for row in db.execute(
|
||||
select(AuthToken).where(
|
||||
AuthToken.user_id == user.id,
|
||||
AuthToken.kind == "reset",
|
||||
AuthToken.kind.in_(_PASSWORD_TOKEN_KINDS),
|
||||
AuthToken.used_at.is_(None),
|
||||
)
|
||||
).scalars():
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.downloads import edit as editmod
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import quota, service, storage
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.downloads import urlguard
|
||||
from app.models import (
|
||||
DownloadJob,
|
||||
DownloadLink,
|
||||
@@ -65,7 +66,18 @@ def resolve_source(raw: str) -> tuple[str, str]:
|
||||
if _VIDEO_ID.match(raw):
|
||||
return "youtube", raw
|
||||
if raw.startswith(("http://", "https://")):
|
||||
u = urlparse(raw)
|
||||
# `urlparse` raises ValueError on a malformed authority ("http://[::1/x"), and this runs
|
||||
# BEFORE the urlguard check that would otherwise turn it into a structured refusal — so
|
||||
# unguarded, that typo escapes as a 500. Answered in urlguard's own shape, because it is the
|
||||
# same typo class: ":99999" is caught there and localized, and one of the two arriving as a
|
||||
# bare English string would be exactly the split the canonical-patterns table forbids.
|
||||
try:
|
||||
u = urlparse(raw)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"code": "url_blocked", "reason": urlguard.REASON_MALFORMED},
|
||||
) from exc
|
||||
host = u.netloc.lower()
|
||||
if "youtube.com" in host:
|
||||
if u.path.startswith(("/shorts/", "/embed/", "/live/")):
|
||||
@@ -352,6 +364,17 @@ def enqueue_download(
|
||||
body: EnqueueDownloadIn, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
source_kind, source_ref = resolve_source(body.source)
|
||||
# SEC-12: a non-YouTube source is a raw URL the worker will fetch server-side, so refuse the
|
||||
# ones pointing back into our own network BEFORE a job exists. The worker re-checks (DNS can
|
||||
# move between here and the download); see downloads/urlguard.py for what that does and does
|
||||
# not cover.
|
||||
if source_kind == "url":
|
||||
try:
|
||||
urlguard.check_url(source_ref)
|
||||
except urlguard.BlockedUrl as e:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "url_blocked", "reason": e.reason}
|
||||
) from e
|
||||
spec, profile_id = _resolve_spec(db, user, body.profile_id, body.spec)
|
||||
display_name = (body.display_name or "").strip() or None
|
||||
try:
|
||||
@@ -532,7 +555,12 @@ def _clean_url(raw) -> str | None:
|
||||
return None
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return None
|
||||
parsed = urlparse(url)
|
||||
# The third site in this module handing user input to `urlparse`, which raises on a malformed
|
||||
# authority. This one already has a "not a plausible URL" answer, so the typo belongs in it.
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
return None
|
||||
if not parsed.netloc:
|
||||
return None
|
||||
return url[:2048]
|
||||
|
||||
@@ -3,6 +3,8 @@ CRUD (create / rename / delete, add / remove / reorder items) plus YouTube two-w
|
||||
mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert local edits back
|
||||
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select, and_
|
||||
@@ -20,10 +22,30 @@ from app.sync.playlists import (
|
||||
repull_playlist,
|
||||
sync_user_playlists,
|
||||
)
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
|
||||
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
||||
|
||||
# SEC-14, same reasoning as the sync module's limiter: a playlist pull lists every playlist the
|
||||
# caller owns and then every item in each, against the instance-wide YouTube budget. Its own
|
||||
# limiter rather than a shared one — spending the manual subscription-import allowance must not
|
||||
# also close the playlist button, and vice versa.
|
||||
#
|
||||
# It guards EVERY route that spends under QuotaAction.PLAYLISTS_PULL, not just the bulk one: a
|
||||
# scripted loop over the single-playlist re-pull drains the same shared budget, and limiting one of
|
||||
# two routes with the same quota action is the asymmetric fix this codebase keeps re-learning.
|
||||
# Deliberately NOT on the push side (`push-plan` / `push`): those run on every push attempt,
|
||||
# including the no-op "already up to date" path, so a 3-per-10-minute allowance would refuse
|
||||
# legitimate work. Their exposure is tracked as SEC-18 instead.
|
||||
_playlist_pull_limiter = RateLimiter(max_events=3, window_seconds=600)
|
||||
|
||||
|
||||
def _check_pull_cooldown(user: User) -> None:
|
||||
"""Shared gate so the two PLAYLISTS_PULL routes cannot drift apart again."""
|
||||
if not _playlist_pull_limiter.allow(str(user.id)):
|
||||
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
||||
|
||||
|
||||
def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||
pl = db.get(Playlist, playlist_id)
|
||||
@@ -32,6 +54,16 @@ def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||
return pl
|
||||
|
||||
|
||||
def _touch(pl: Playlist) -> None:
|
||||
"""Bump the playlist's `updated_at` for an edit that only writes `playlist_items`.
|
||||
|
||||
`UpdatedAtMixin`'s `onupdate` fires on an UPDATE of the PLAYLIST row, and add / remove /
|
||||
reorder / move write the item rows instead — so without this the rail's "recently changed"
|
||||
sort would only ever see renames. The assignment is what makes the column mean "last time
|
||||
this list changed", which is what the rail promises the reader."""
|
||||
pl.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _next_playlist_position(db: Session, user_id: int) -> int:
|
||||
"""Append position for a new playlist in this user's rail."""
|
||||
return (
|
||||
@@ -71,6 +103,7 @@ def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) ->
|
||||
)
|
||||
if mark_dirty:
|
||||
recompute_dirty(db, pl)
|
||||
_touch(pl)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@@ -88,6 +121,7 @@ def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool)
|
||||
db.delete(item)
|
||||
if mark_dirty:
|
||||
recompute_dirty(db, pl)
|
||||
_touch(pl)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@@ -109,6 +143,9 @@ def _summary(
|
||||
"item_count": count,
|
||||
"total_duration_seconds": total_duration,
|
||||
"cover_thumbnail": cover,
|
||||
# When this list last CHANGED (rename, add/remove/move/reorder) — the rail's
|
||||
# "recently changed first" sort reads it. See _touch for why it isn't automatic.
|
||||
"updated_at": pl.updated_at,
|
||||
}
|
||||
if has_video is not None:
|
||||
out["has_video"] = has_video
|
||||
@@ -284,6 +321,7 @@ def sync_youtube(
|
||||
status_code=403,
|
||||
detail="Connect YouTube (read access) to sync your playlists.",
|
||||
)
|
||||
_check_pull_cooldown(user)
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||
return sync_user_playlists(db, user)
|
||||
|
||||
@@ -304,6 +342,7 @@ def revert_youtube(
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Connect YouTube (read access) to sync your playlists."
|
||||
)
|
||||
_check_pull_cooldown(user)
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||
return repull_playlist(db, user, pl)
|
||||
@@ -498,6 +537,63 @@ def remove_item(
|
||||
return {"playlist_id": pl.id, "video_id": video_id}
|
||||
|
||||
|
||||
class MoveItemIn(BaseModel):
|
||||
target_playlist_id: int
|
||||
|
||||
|
||||
@router.post("/{playlist_id}/items/{video_id}/move")
|
||||
def move_item(
|
||||
playlist_id: int,
|
||||
video_id: str,
|
||||
body: MoveItemIn,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Move one video from a playlist into another of the caller's playlists, atomically.
|
||||
|
||||
ONE transaction on purpose. The obvious client-side shape — add to the target, then remove
|
||||
from the source — leaves the video in BOTH lists whenever the second call fails, which is
|
||||
exactly the kind of half-applied state this codebase treats as a bug rather than an edge
|
||||
case. Idempotent: if the video is already in the target, the source row is simply dropped
|
||||
(moving into a list that already has it is a removal, not a duplicate)."""
|
||||
src = _own_playlist(db, user, playlist_id)
|
||||
dst = _own_playlist(db, user, body.target_playlist_id)
|
||||
if src.id == dst.id:
|
||||
raise HTTPException(
|
||||
status_code=400, detail={"code": "playlist_move", "reason": "same_playlist"}
|
||||
)
|
||||
item = db.scalar(
|
||||
select(PlaylistItem).where(
|
||||
PlaylistItem.playlist_id == src.id, PlaylistItem.video_id == video_id
|
||||
)
|
||||
)
|
||||
if item is None:
|
||||
# A concurrent edit (another tab, or a YouTube re-pull) already took it out.
|
||||
raise HTTPException(
|
||||
status_code=404, detail={"code": "playlist_move", "reason": "not_in_playlist"}
|
||||
)
|
||||
already = db.scalar(
|
||||
select(PlaylistItem).where(
|
||||
PlaylistItem.playlist_id == dst.id, PlaylistItem.video_id == video_id
|
||||
)
|
||||
)
|
||||
if already is not None:
|
||||
db.delete(item) # the uq_playlist_video constraint would reject a second row anyway
|
||||
else:
|
||||
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
|
||||
# survives the move, and there is no window where the video belongs to neither list.
|
||||
item.playlist_id = dst.id
|
||||
item.position = _next_item_position(db, dst.id)
|
||||
# Both sides changed, so both get the dirty recompute and the timestamp — the asymmetric
|
||||
# version of this (touching only the source) is the recurring smell CLAUDE.md warns about.
|
||||
recompute_dirty(db, src)
|
||||
recompute_dirty(db, dst)
|
||||
_touch(src)
|
||||
_touch(dst)
|
||||
db.commit()
|
||||
return {"ok": True, "from_playlist_id": src.id, "to_playlist_id": dst.id, "video_id": video_id}
|
||||
|
||||
|
||||
class ReorderItemsIn(BaseModel):
|
||||
video_ids: list[str] = []
|
||||
|
||||
@@ -533,5 +629,6 @@ def reorder_items(
|
||||
it.position = pos
|
||||
pos += 1
|
||||
recompute_dirty(db, pl)
|
||||
_touch(pl)
|
||||
db.commit()
|
||||
return {"playlist_id": pl.id, "count": pos}
|
||||
|
||||
@@ -16,6 +16,7 @@ from sqlalchemy.orm import Session
|
||||
from app import email as email_mod
|
||||
from app import mailtext
|
||||
from app import state, sysconfig
|
||||
from app.utils import canonical_email
|
||||
from app.auth import PASSWORD_MIN_LEN
|
||||
from app.db import get_db
|
||||
from app.models import User
|
||||
@@ -70,7 +71,13 @@ def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dic
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Password must be at least {PASSWORD_MIN_LEN} characters."
|
||||
)
|
||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
# Resolve canonically, not by the literal string (DX-17). The wizard can be re-run, and E9.5's
|
||||
# whole point is that two spellings of one Gmail mailbox are one person — matching literally here
|
||||
# would let a second run under the other spelling create the DUPLICATE admin row the epic exists
|
||||
# to prevent. Every other lookup went canonical in S3; this one was outside that diff.
|
||||
user = db.execute(
|
||||
select(User).where(User.email_canonical == canonical_email(email))
|
||||
).scalar_one_or_none()
|
||||
if user is None:
|
||||
user = User(email=email, role="admin")
|
||||
db.add(user)
|
||||
|
||||
@@ -7,12 +7,20 @@ from app.audit import AuditAction
|
||||
from app.auth import admin_user, current_user, has_read_scope, require_human
|
||||
from app.db import get_db
|
||||
from app.models import Channel, Subscription, User, Video
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.sync.runner import estimate_deep_backfill
|
||||
from app.sync.subscriptions import import_subscriptions
|
||||
from app.scheduler import running_job_ids
|
||||
|
||||
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
||||
|
||||
# SEC-14: the YouTube API budget is SHARED across the whole instance, and a subscription import
|
||||
# walks every page of the caller's subscriptions — so one user holding down a manual sync button can
|
||||
# spend the day's units for everyone. Keyed by user id, not client IP: the cost belongs to the
|
||||
# account, and the same person on a second device is the same drain. 3 per 10 minutes leaves manual
|
||||
# re-syncing entirely usable (the scheduler does the routine work anyway) while removing the loop.
|
||||
_sync_limiter = RateLimiter(max_events=3, window_seconds=600)
|
||||
|
||||
|
||||
@router.post("/subscriptions")
|
||||
def sync_subscriptions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
|
||||
@@ -22,6 +30,8 @@ def sync_subscriptions(user: User = Depends(require_human), db: Session = Depend
|
||||
status_code=403,
|
||||
detail="Connect your YouTube account (read access) to import subscriptions.",
|
||||
)
|
||||
if not _sync_limiter.allow(str(user.id)):
|
||||
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
||||
with quota.measured(db, user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL) as m:
|
||||
result = import_subscriptions(db, user)
|
||||
result["quota_used_estimate"] = m.used
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""E12 · S3 — the route-level halves of SEC-12 and SEC-14.
|
||||
|
||||
`test_urlguard.py` pins the address judgement itself; this pins that the ENQUEUE route actually
|
||||
consults it and answers in the structured `{code, reason}` shape the frontend localizes, and that a
|
||||
manual sync trigger stops spending the instance-wide YouTube budget when held down.
|
||||
|
||||
DB-backed (the `as_user` fixture needs real rows) — skipped when no database is reachable.
|
||||
"""
|
||||
|
||||
from app.downloads import urlguard
|
||||
|
||||
|
||||
def test_enqueueing_a_loopback_url_is_refused_with_a_structured_detail(
|
||||
db, as_user, make_user, monkeypatch
|
||||
):
|
||||
user = make_user()
|
||||
db.commit()
|
||||
monkeypatch.setattr(
|
||||
urlguard.socket, "getaddrinfo", lambda *a, **kw: [(None, None, None, "", ("127.0.0.1", 0))]
|
||||
)
|
||||
r = as_user(user).post("/api/downloads", json={"source": "http://redis.internal:6379/x"})
|
||||
assert r.status_code == 422
|
||||
# The shape matters as much as the refusal: a bare English string under a bilingual UI is the
|
||||
# thing the canonical-patterns table forbids.
|
||||
assert r.json()["detail"] == {"code": "url_blocked", "reason": urlguard.REASON_PRIVATE}
|
||||
|
||||
|
||||
def test_a_public_url_gets_past_the_guard(db, as_user, make_user, monkeypatch):
|
||||
# Mutation check for the test above: with a public answer the request must fail LATER (or
|
||||
# succeed), never with url_blocked — otherwise the guard would be refusing everything and the
|
||||
# first test would still pass.
|
||||
user = make_user()
|
||||
db.commit()
|
||||
monkeypatch.setattr(
|
||||
urlguard.socket,
|
||||
"getaddrinfo",
|
||||
lambda *a, **kw: [(None, None, None, "", ("93.184.216.34", 0))],
|
||||
)
|
||||
r = as_user(user).post("/api/downloads", json={"source": "https://example.com/v"})
|
||||
detail = r.json().get("detail") if r.status_code >= 400 else None
|
||||
assert not (isinstance(detail, dict) and detail.get("code") == "url_blocked")
|
||||
|
||||
|
||||
def test_youtube_sources_never_consult_the_guard(db, as_user, make_user, monkeypatch):
|
||||
# A YouTube job resolves to a fixed public host from an 11-char id, so there is no
|
||||
# user-controlled destination — resolving one would be pure latency on every enqueue.
|
||||
def boom(*a, **kw):
|
||||
raise AssertionError("the URL guard resolved DNS for a YouTube source")
|
||||
|
||||
user = make_user()
|
||||
db.commit()
|
||||
monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom)
|
||||
as_user(user).post("/api/downloads", json={"source": "dQw4w9WgXcQ"})
|
||||
|
||||
|
||||
def test_manual_subscription_sync_goes_on_cooldown(db, as_user, make_user, monkeypatch):
|
||||
from app.routes import sync as syncroutes
|
||||
|
||||
user = make_user()
|
||||
db.commit()
|
||||
# Neither the YouTube grant nor the import itself is under test — the limiter is.
|
||||
monkeypatch.setattr(syncroutes, "has_read_scope", lambda u: True)
|
||||
monkeypatch.setattr(syncroutes, "import_subscriptions", lambda d, u: {"imported": 0})
|
||||
|
||||
c = as_user(user)
|
||||
codes = [c.post("/api/sync/subscriptions").status_code for _ in range(4)]
|
||||
# 3 per 10 minutes: the first three pass, the fourth is refused.
|
||||
assert codes[:3] == [200, 200, 200]
|
||||
assert codes[3] == 429
|
||||
assert c.post("/api/sync/subscriptions").json()["detail"] == {"code": "sync_cooldown"}
|
||||
|
||||
|
||||
def test_the_cooldown_is_per_user_not_global(db, as_user, make_user, monkeypatch):
|
||||
# Keyed by user id on purpose: the shared YouTube budget is spent per account, and one user
|
||||
# exhausting their allowance must not lock everyone else out of their own sync button.
|
||||
from app.routes import sync as syncroutes
|
||||
|
||||
a, b = make_user(), make_user()
|
||||
db.commit()
|
||||
monkeypatch.setattr(syncroutes, "has_read_scope", lambda u: True)
|
||||
monkeypatch.setattr(syncroutes, "import_subscriptions", lambda d, u: {"imported": 0})
|
||||
|
||||
ca = as_user(a)
|
||||
for _ in range(4):
|
||||
ca.post("/api/sync/subscriptions")
|
||||
assert ca.post("/api/sync/subscriptions").status_code == 429
|
||||
assert as_user(b).post("/api/sync/subscriptions").status_code == 200
|
||||
|
||||
|
||||
def test_the_single_playlist_repull_shares_the_pull_cooldown(db, as_user, make_user, monkeypatch):
|
||||
# SEC-14, the sweep half: `revert-youtube` spends under the SAME QuotaAction.PLAYLISTS_PULL key
|
||||
# as the bulk `sync-youtube`, so limiting only one of them left the identical drain open one
|
||||
# route over. Both must draw on the one allowance.
|
||||
from app.models import Playlist
|
||||
from app.routes import playlists as plroutes
|
||||
|
||||
user = make_user()
|
||||
pl = Playlist(user_id=user.id, name="Linked", source="youtube", yt_playlist_id="PL123")
|
||||
db.add(pl)
|
||||
db.commit()
|
||||
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
|
||||
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: {"ok": True})
|
||||
monkeypatch.setattr(plroutes, "sync_user_playlists", lambda d, u: {"ok": True})
|
||||
|
||||
c = as_user(user)
|
||||
codes = [c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code for _ in range(4)]
|
||||
assert codes[:3] == [200, 200, 200]
|
||||
assert codes[3] == 429
|
||||
# One allowance, not one each: the bulk route is now on cooldown too.
|
||||
r = c.post("/api/playlists/sync-youtube")
|
||||
assert r.status_code == 429
|
||||
assert r.json()["detail"] == {"code": "sync_cooldown"}
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Category classification of raw yt-dlp / ffmpeg download-failure strings (S2)."""
|
||||
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads.errors import classify
|
||||
|
||||
|
||||
@@ -54,3 +55,22 @@ def test_login_beats_generic_unavailable_when_both_present():
|
||||
"This video is unavailable"
|
||||
)
|
||||
assert classify(msg) == "login_required"
|
||||
|
||||
|
||||
def test_our_own_worker_refusals_classify_as_themselves():
|
||||
# The worker raises these itself (not yt-dlp), and the rule that catches each one matches only a
|
||||
# fragment of the text. Pinning message and rule together is what stops an edit to the wording
|
||||
# from silently orphaning the category — the UI would then fall back to "Download failed".
|
||||
assert classify(dlerrors.QUOTA_FULL_MESSAGE) == "quota_exceeded"
|
||||
|
||||
|
||||
def test_a_url_jobs_network_failure_is_redacted_but_youtube_keeps_its_text():
|
||||
# SEC-12: the raw transport text for a user-chosen host:port is a port-scanner readout, so it is
|
||||
# replaced for `url` jobs. Everything else must survive verbatim, or a real diagnosis is lost.
|
||||
raw = "ERROR: Unable to download webpage: <urlopen error [Errno 111] Connection refused>"
|
||||
assert dlerrors.user_error_text("url", raw, "network") == dlerrors.NETWORK_REDACTED_MESSAGE
|
||||
# Same failure, but the destination was never user-chosen — nothing to leak, keep the detail.
|
||||
assert dlerrors.user_error_text("youtube", raw, "network") == raw
|
||||
# A user-chosen host, but a category that says something real about the CONTENT, not the network.
|
||||
login = "ERROR: [facebook] 123: You must be logged in to see this post"
|
||||
assert dlerrors.user_error_text("url", login, "login_required") == login
|
||||
|
||||
@@ -10,6 +10,8 @@ Uses the `db` fixture (real Postgres) — skipped when no database is reachable.
|
||||
its OWN session, so every test COMMITS its setup before calling it.
|
||||
"""
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import pipeline, quota
|
||||
from app.models import DownloadJob, DownloadQuota, MediaAsset, User
|
||||
from app.worker import _claim_job
|
||||
|
||||
@@ -169,3 +171,110 @@ def test_list_downloads_is_capped(db, monkeypatch):
|
||||
monkeypatch.setattr(dl, "_MAX_LIST", 2)
|
||||
rows = dl.list_downloads(user=u, db=db)
|
||||
assert len(rows) == 2 # bounded by the cap, not the 3 rows on disk
|
||||
|
||||
|
||||
# --- E12 · S3 · the worker-side max_bytes remainder ------------------------------------------
|
||||
|
||||
|
||||
def _sized_asset(db, size_bytes, ref):
|
||||
a = MediaAsset(
|
||||
source_kind="url",
|
||||
source_ref=ref,
|
||||
format_sig="s",
|
||||
status="ready",
|
||||
ref_count=1,
|
||||
size_bytes=size_bytes,
|
||||
)
|
||||
db.add(a)
|
||||
db.flush()
|
||||
return a
|
||||
|
||||
|
||||
def test_remaining_bytes_is_none_for_an_unlimited_user(db):
|
||||
u = User(email="unlimited@example.com")
|
||||
db.add(u)
|
||||
db.flush()
|
||||
_quota(db, u.id, max_concurrent=2, unlimited=True)
|
||||
db.flush()
|
||||
# None, not a number: "no cap" and "no budget left" must never be the same value downstream —
|
||||
# build_ydl_opts leaves max_filesize unset for both, so only the caller can tell them apart.
|
||||
assert quota.remaining_bytes(db, u.id) is None
|
||||
|
||||
|
||||
def test_remaining_bytes_is_the_unspent_part_of_the_cap(db):
|
||||
u = User(email="partial@example.com")
|
||||
db.add(u)
|
||||
db.flush()
|
||||
db.add(
|
||||
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False)
|
||||
)
|
||||
a = _sized_asset(db, 400, "partial-1")
|
||||
_job(db, u.id, queue_pos=0, status="done", asset_id=a.id)
|
||||
db.flush()
|
||||
assert quota.remaining_bytes(db, u.id) == 600
|
||||
|
||||
|
||||
def test_remaining_bytes_is_zero_once_the_cap_is_met(db):
|
||||
# THE case this exists for: `check_enqueue` let these jobs in while the user was under the cap,
|
||||
# and an earlier one has filled it since. Zero is what makes the worker refuse; if this ever
|
||||
# returned the raw negative or None instead, the user already over quota would be the only one
|
||||
# downloading with no size ceiling at all.
|
||||
u = User(email="atcap@example.com")
|
||||
db.add(u)
|
||||
db.flush()
|
||||
db.add(
|
||||
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False)
|
||||
)
|
||||
a = _sized_asset(db, 1500, "atcap-1") # overshot: one job finished larger than the remainder
|
||||
_job(db, u.id, queue_pos=0, status="done", asset_id=a.id)
|
||||
db.flush()
|
||||
assert quota.remaining_bytes(db, u.id) == 0
|
||||
|
||||
|
||||
def test_a_refusal_for_one_user_does_not_poison_the_shared_asset(db, monkeypatch, tmp_path):
|
||||
"""A user at their cap fails their own job, and another user's queued job for the same video
|
||||
still downloads.
|
||||
|
||||
MediaAssets are deduped across users, and the generic failure handler writes the message onto
|
||||
that shared row — correct for an upstream failure, wrong for "YOUR storage is full", which
|
||||
`worker._process_job` would then hand verbatim to someone with an empty library.
|
||||
"""
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||
over, other = User(email="over@example.com"), User(email="other@example.com")
|
||||
db.add_all([over, other])
|
||||
db.flush()
|
||||
db.add(
|
||||
DownloadQuota(
|
||||
user_id=over.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False
|
||||
)
|
||||
)
|
||||
full = _sized_asset(db, 1500, "already-held")
|
||||
_job(db, over.id, queue_pos=0, status="done", asset_id=full.id)
|
||||
|
||||
# `downloading` is the state the worker leaves it in — `_claim_asset` compare-and-sets it before
|
||||
# the child runs — and `_reset_asset_pending` only acts on that status. Seeded as `pending` this
|
||||
# assertion would pass with the release deleted.
|
||||
shared = MediaAsset(
|
||||
source_kind="youtube",
|
||||
source_ref="x" * 11,
|
||||
format_sig="s",
|
||||
status="downloading",
|
||||
ref_count=2,
|
||||
)
|
||||
db.add(shared)
|
||||
db.flush()
|
||||
mine = _job(db, over.id, queue_pos=1, status="running", asset_id=shared.id)
|
||||
theirs = _job(db, other.id, queue_pos=2, asset_id=shared.id)
|
||||
db.commit()
|
||||
|
||||
# The refusal happens before extraction, so nothing needs stubbing — if it ever stops happening
|
||||
# first, this test starts reaching the network, which is its own signal.
|
||||
pipeline._download(mine.id, shared.id, "youtube", "x" * 11, {}, over.id)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, mine.id).status == "error"
|
||||
assert db.get(DownloadJob, mine.id).error_code == "quota_exceeded"
|
||||
# The asset is released, NOT marked error — otherwise the next line's job inherits the refusal.
|
||||
assert db.get(MediaAsset, shared.id).status == "pending"
|
||||
assert db.get(MediaAsset, shared.id).error is None
|
||||
assert db.get(DownloadJob, theirs.id).status == "queued"
|
||||
|
||||
@@ -345,3 +345,28 @@ def test_me_reports_the_google_address_only_when_it_differs(client, db, make_use
|
||||
== 200
|
||||
)
|
||||
assert client.get("/api/me").json()["google_email"] == "me@gmail.com"
|
||||
|
||||
|
||||
# --- DX-17 · the setup wizard resolves its admin canonically ---------------------------------
|
||||
|
||||
|
||||
def test_rerunning_the_wizard_with_another_gmail_spelling_updates_the_same_admin(db):
|
||||
"""The wizard can be re-run, and E9.5's premise is that two spellings of one Gmail mailbox are one
|
||||
person. Resolving by the literal string here would create a SECOND admin row — exactly the
|
||||
duplicate the epic exists to prevent. This site was outside S3's sweep (DX-17).
|
||||
|
||||
Calls the handler directly: `require_setup` is an instance-state gate, not the behaviour under
|
||||
test, and the test fixtures present an already-configured instance."""
|
||||
from app.routes.setup import SetupAdminIn, setup_admin
|
||||
|
||||
setup_admin(SetupAdminIn(email="a.b.c@gmail.com", password=_UAT_PW), db)
|
||||
setup_admin(SetupAdminIn(email="abc@gmail.com", password="another good passphrase"), db)
|
||||
|
||||
admins = db.execute(select(User).where(User.role == "admin")).scalars().all()
|
||||
assert len(admins) == 1, [u.email for u in admins]
|
||||
# The address stays AS TYPED on the row it matched; only identity is canonical.
|
||||
assert admins[0].email == "a.b.c@gmail.com"
|
||||
# And the second run's password is the live one — it updated, it did not fork.
|
||||
from app.security import verify_password
|
||||
|
||||
assert verify_password("another good passphrase", admins[0].password_hash)
|
||||
|
||||
@@ -109,3 +109,52 @@ def test_browser_transcode_flags_universal_playability():
|
||||
assert F("h264", "aac", False) == (True, False, False)
|
||||
# A failed probe / missing stream is never re-encoded.
|
||||
assert F(None, None, True) == (False, False, False)
|
||||
|
||||
|
||||
# --- SEC-13 · one enqueue is one bounded download -------------------------------------------
|
||||
|
||||
|
||||
def test_a_playlist_url_can_only_yield_one_item():
|
||||
# `noplaylist` alone only demotes a video-inside-a-playlist URL to that video; a URL that is
|
||||
# purely a playlist or channel has no such video, so without this it expands into every entry.
|
||||
opts = _opts({})
|
||||
assert opts["noplaylist"] is True
|
||||
assert opts["playlist_items"] == "1"
|
||||
|
||||
|
||||
def test_the_remaining_quota_becomes_a_hard_size_ceiling():
|
||||
opts = formats.build_ydl_opts({}, "/tmp/x", lambda d: None, max_bytes=5_000_000)
|
||||
assert opts["max_filesize"] == 5_000_000
|
||||
|
||||
|
||||
def test_no_budget_means_no_size_filter():
|
||||
# Unlimited users pass None; a non-positive remainder is the quota layer's refusal to make at
|
||||
# enqueue, not an impossible yt-dlp filter to express here.
|
||||
for budget in (None, 0, -1):
|
||||
assert "max_filesize" not in formats.build_ydl_opts(
|
||||
{}, "/tmp/x", lambda d: None, max_bytes=budget
|
||||
)
|
||||
|
||||
|
||||
def test_a_source_adapter_cannot_raise_the_size_ceiling():
|
||||
# The adapter merge happens after max_filesize is set, so this pins that no adapter option can
|
||||
# widen a storage cap — the mirror of the cookiefile rule, in the opposite direction.
|
||||
opts = formats.build_ydl_opts(
|
||||
{}, "/tmp/x", lambda d: None, extra={"max_filesize": 999_999_999}, max_bytes=5_000_000
|
||||
)
|
||||
assert opts["max_filesize"] == 5_000_000
|
||||
|
||||
|
||||
def test_a_join_cut_list_is_capped():
|
||||
# An accurate join emits two filters per segment into one ffmpeg filter_complex, so an unbounded
|
||||
# list is an unbounded command line. The surplus is dropped; the legitimate head survives.
|
||||
segs = [{"start_s": i, "end_s": i + 1} for i in range(500)]
|
||||
out = editmod.normalize_edit_spec({"segments": segs})
|
||||
assert len(out["segments"]) == editmod._MAX_SEGMENTS
|
||||
assert out["segments"][0] == {"start_s": 0.0, "end_s": 1.0}
|
||||
|
||||
|
||||
def test_a_normal_cut_list_is_untouched():
|
||||
segs = [{"start_s": 0, "end_s": 5}, {"start_s": 10, "end_s": 12}]
|
||||
out = editmod.normalize_edit_spec({"segments": segs})
|
||||
assert len(out["segments"]) == 2
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""E10 S2 — the one-step "move this video to another list" endpoint, and the `updated_at` touch
|
||||
the rail's "recently changed" sort reads.
|
||||
|
||||
Two properties are load-bearing and neither is visible from the happy path alone:
|
||||
|
||||
1. **The move is one transaction.** The client-side shape it replaces (add to the target, then
|
||||
remove from the source) leaves the video in BOTH lists when the second call fails. So the
|
||||
tests assert the *pair* of lists after every call, never just the destination.
|
||||
2. **`updated_at` tracks item edits.** `UpdatedAtMixin.onupdate` only fires on an UPDATE of the
|
||||
playlist row, and add/remove/reorder/move write `playlist_items` — so the timestamp is bumped
|
||||
explicitly, and a regression there would silently degrade the sort to "renames only".
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.models import Channel, Playlist, PlaylistItem, Video
|
||||
|
||||
|
||||
def _seed_videos(db, ids):
|
||||
db.add(Channel(id="UCtest", title="Test"))
|
||||
now = datetime.now(timezone.utc)
|
||||
for v in ids:
|
||||
db.add(Video(id=v, channel_id="UCtest", published_at=now))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _playlist(db, user, name, video_ids, **kw):
|
||||
pl = Playlist(user_id=user.id, name=name, **kw)
|
||||
db.add(pl)
|
||||
db.flush()
|
||||
for i, vid in enumerate(video_ids):
|
||||
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=i))
|
||||
db.commit()
|
||||
return pl
|
||||
|
||||
|
||||
def _ids(db, pl_id):
|
||||
return [
|
||||
it.video_id
|
||||
for it in db.execute(
|
||||
PlaylistItem.__table__.select()
|
||||
.where(PlaylistItem.playlist_id == pl_id)
|
||||
.order_by(PlaylistItem.position)
|
||||
).all()
|
||||
]
|
||||
|
||||
|
||||
def _move(client, src, video_id, dst):
|
||||
return client.post(
|
||||
f"/api/playlists/{src}/items/{video_id}/move", json={"target_playlist_id": dst}
|
||||
)
|
||||
|
||||
|
||||
def test_move_leaves_the_video_in_exactly_one_list(db, make_user, as_user):
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1", "v2"])
|
||||
src = _playlist(db, user, "Source", ["v1", "v2"])
|
||||
dst = _playlist(db, user, "Target", [])
|
||||
|
||||
r = _move(as_user(user), src.id, "v1", dst.id)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {
|
||||
"ok": True,
|
||||
"from_playlist_id": src.id,
|
||||
"to_playlist_id": dst.id,
|
||||
"video_id": "v1",
|
||||
}
|
||||
db.expire_all()
|
||||
assert _ids(db, src.id) == ["v2"]
|
||||
assert _ids(db, dst.id) == ["v1"]
|
||||
|
||||
|
||||
def test_move_appends_to_the_end_of_the_target(db, make_user, as_user):
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1", "v2", "v3"])
|
||||
src = _playlist(db, user, "Source", ["v3"])
|
||||
dst = _playlist(db, user, "Target", ["v1", "v2"])
|
||||
|
||||
assert _move(as_user(user), src.id, "v3", dst.id).status_code == 200
|
||||
db.expire_all()
|
||||
assert _ids(db, dst.id) == ["v1", "v2", "v3"]
|
||||
|
||||
|
||||
def test_move_into_a_list_that_already_has_it_is_a_removal_not_a_duplicate(db, make_user, as_user):
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1"])
|
||||
src = _playlist(db, user, "Source", ["v1"])
|
||||
dst = _playlist(db, user, "Target", ["v1"])
|
||||
|
||||
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
|
||||
db.expire_all()
|
||||
assert _ids(db, src.id) == []
|
||||
assert _ids(db, dst.id) == ["v1"] # exactly one row, not two
|
||||
|
||||
|
||||
def test_move_to_the_same_list_is_refused(db, make_user, as_user):
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1"])
|
||||
src = _playlist(db, user, "Source", ["v1"])
|
||||
|
||||
r = _move(as_user(user), src.id, "v1", src.id)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == {"code": "playlist_move", "reason": "same_playlist"}
|
||||
db.expire_all()
|
||||
assert _ids(db, src.id) == ["v1"]
|
||||
|
||||
|
||||
def test_move_of_a_video_that_left_the_source_reports_it(db, make_user, as_user):
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1"])
|
||||
src = _playlist(db, user, "Source", [])
|
||||
dst = _playlist(db, user, "Target", [])
|
||||
|
||||
r = _move(as_user(user), src.id, "v1", dst.id)
|
||||
assert r.status_code == 404
|
||||
assert r.json()["detail"] == {"code": "playlist_move", "reason": "not_in_playlist"}
|
||||
|
||||
|
||||
def test_move_into_someone_elses_list_404s_and_changes_nothing(db, make_user, as_user):
|
||||
user, other = make_user(), make_user()
|
||||
_seed_videos(db, ["v1"])
|
||||
src = _playlist(db, user, "Mine", ["v1"])
|
||||
theirs = _playlist(db, other, "Theirs", [])
|
||||
|
||||
assert _move(as_user(user), src.id, "v1", theirs.id).status_code == 404
|
||||
db.expire_all()
|
||||
assert _ids(db, src.id) == ["v1"]
|
||||
assert _ids(db, theirs.id) == []
|
||||
|
||||
|
||||
def test_move_marks_both_linked_sides_dirty(db, make_user, as_user):
|
||||
"""A move edits two YouTube mirrors at once — the source lost an item, the target gained one.
|
||||
Marking only the source is the asymmetric fix this repo keeps re-learning."""
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1"])
|
||||
src = _playlist(db, user, "Src", ["v1"], yt_playlist_id="PLsrc", synced_video_ids='["v1"]')
|
||||
dst = _playlist(db, user, "Dst", [], yt_playlist_id="PLdst", synced_video_ids="[]")
|
||||
# Baseline: both are in sync with YouTube before the move.
|
||||
src.dirty = False
|
||||
dst.dirty = False
|
||||
db.commit()
|
||||
|
||||
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(Playlist, src.id).dirty is True
|
||||
assert db.get(Playlist, dst.id).dirty is True
|
||||
|
||||
|
||||
def test_item_edits_bump_updated_at(db, make_user, as_user):
|
||||
"""The rail's "recently changed first" sort reads this. Adding, moving and reordering all
|
||||
write `playlist_items` only, so nothing here is covered by the ORM's `onupdate`."""
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1", "v2"])
|
||||
src = _playlist(db, user, "Source", ["v1", "v2"])
|
||||
dst = _playlist(db, user, "Target", [])
|
||||
old = datetime.now(timezone.utc) - timedelta(days=3)
|
||||
src.updated_at = old
|
||||
dst.updated_at = old
|
||||
db.commit()
|
||||
client = as_user(user)
|
||||
|
||||
assert client.post(f"/api/playlists/{dst.id}/items", json={"video_id": "v1"}).status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(Playlist, dst.id).updated_at > old
|
||||
|
||||
dst.updated_at = old
|
||||
db.commit()
|
||||
assert (
|
||||
client.put(f"/api/playlists/{src.id}/order", json={"video_ids": ["v2", "v1"]}).status_code
|
||||
== 200
|
||||
)
|
||||
db.expire_all()
|
||||
assert db.get(Playlist, src.id).updated_at > old
|
||||
|
||||
src.updated_at = old
|
||||
dst.updated_at = old
|
||||
db.commit()
|
||||
assert _move(client, src.id, "v2", dst.id).status_code == 200
|
||||
db.expire_all()
|
||||
# BOTH sides of a move are "recently changed" — the target is where the reader will look.
|
||||
assert db.get(Playlist, src.id).updated_at > old
|
||||
assert db.get(Playlist, dst.id).updated_at > old
|
||||
|
||||
|
||||
def test_the_list_endpoint_publishes_updated_at(db, make_user, as_user):
|
||||
user = make_user()
|
||||
_playlist(db, user, "Solo", [])
|
||||
rows = as_user(user).get("/api/playlists").json()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["updated_at"] # the rail cannot sort by a field the API never sends
|
||||
@@ -0,0 +1,280 @@
|
||||
"""E12 · S5 — the admin-issued set-password link.
|
||||
|
||||
One mechanism answering three things: SEC-15 (a no-SMTP instance auto-verifies every registration, so
|
||||
a pre-empted address cannot be recovered by mail), SEC-16 (a pre-empted address stays takeable until
|
||||
its owner resets, and reset needs working mail), and the everyday "no SMTP and someone forgot theirs".
|
||||
|
||||
The property that must not regress is the one that makes it honest: an admin-issued link proves
|
||||
NOTHING about the mailbox — the admin hands it over by whatever means they like — so it must not
|
||||
confirm the address the way a mailed reset does.
|
||||
"""
|
||||
|
||||
from app.models import AuthToken
|
||||
|
||||
|
||||
def _link(client_admin, user_id: int):
|
||||
r = client_admin.post(f"/api/admin/users/{user_id}/set-password-link")
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["url"].split("#reset=", 1)[1]
|
||||
|
||||
|
||||
def test_the_link_sets_a_password_without_confirming_the_address(db, as_user, make_user, client):
|
||||
"""The whole point of a separate token kind. A mailed reset arrives IN the mailbox, so completing
|
||||
it proves ownership and confirms the address. An admin-issued link does not, and claiming
|
||||
otherwise would let an admin confirm an address nobody ever proved."""
|
||||
admin = make_user(role="admin")
|
||||
target = make_user(email_verified=False)
|
||||
db.commit()
|
||||
|
||||
token = _link(as_user(admin), target.id)
|
||||
r = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "a brand new passphrase"}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
db.refresh(target)
|
||||
assert target.password_hash is not None
|
||||
assert target.email_verified is False # the address is still unproven — and says so
|
||||
|
||||
|
||||
def test_a_mailed_reset_still_confirms_the_address(db, as_user, make_user, client):
|
||||
"""The mutation check for the test above: if `email_verified` were simply never set, this would
|
||||
fail — and the rescue path for "registered but never clicked the link" would be gone."""
|
||||
from datetime import timedelta
|
||||
|
||||
from app.routes.auth import _issue_token
|
||||
|
||||
user = make_user(email_verified=False)
|
||||
db.commit()
|
||||
token = _issue_token(db, user, "reset", timedelta(hours=1))
|
||||
|
||||
r = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "a brand new passphrase"}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
db.refresh(user)
|
||||
assert user.email_verified is True
|
||||
|
||||
|
||||
def test_using_the_link_evicts_whoever_held_the_old_password(db, as_user, make_user, client):
|
||||
"""SEC-16's actual remedy: the squatter's session dies the moment the real owner sets a password.
|
||||
The epoch bump is what does it, and it is the reason this reuses the reset confirm path rather
|
||||
than writing a second one that could forget to bump."""
|
||||
target = make_user()
|
||||
db.commit()
|
||||
before = target.session_epoch
|
||||
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
token = _link(as_user(admin), target.id)
|
||||
client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "the owner's own choice"}
|
||||
)
|
||||
|
||||
db.refresh(target)
|
||||
assert target.session_epoch != before
|
||||
|
||||
|
||||
def test_issuing_does_not_touch_the_current_password(db, as_user, make_user):
|
||||
"""An admin misclick must not lock anyone out. Issuing is an offer; only USING it changes
|
||||
anything — which is also why it is safe to offer on a perfectly healthy account."""
|
||||
target = make_user()
|
||||
db.commit()
|
||||
original = target.password_hash
|
||||
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
_link(as_user(admin), target.id)
|
||||
|
||||
db.refresh(target)
|
||||
assert target.password_hash == original
|
||||
assert target.password_revoked_at is None
|
||||
|
||||
|
||||
def test_the_link_is_single_use(db, as_user, make_user, client):
|
||||
target = make_user()
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
|
||||
token = _link(as_user(admin), target.id)
|
||||
first = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "first passphrase here"}
|
||||
)
|
||||
second = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "second passphrase here"}
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 400
|
||||
|
||||
|
||||
def test_a_new_link_burns_the_outstanding_ones(db, as_user, make_user, client):
|
||||
"""Spending any password token burns the siblings of BOTH kinds. Without that, a spent reset
|
||||
would leave a live admin link behind — a second key to a door someone just changed the lock on."""
|
||||
target = make_user()
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
|
||||
first = _link(as_user(admin), target.id)
|
||||
second = _link(as_user(admin), target.id)
|
||||
|
||||
ok = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": second, "password": "the newer passphrase"}
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
stale = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": first, "password": "the older passphrase"}
|
||||
)
|
||||
assert stale.status_code == 400
|
||||
|
||||
|
||||
def test_the_probe_recognises_an_admin_link(db, as_user, make_user, client):
|
||||
"""`/auth/password-reset/check` is what stops the card drawing a form for a dead link. It peeked
|
||||
only at `reset` tokens, so without teaching it the new kind every admin-issued link would open
|
||||
onto "this link is spent"."""
|
||||
target = make_user()
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
|
||||
token = _link(as_user(admin), target.id)
|
||||
r = client.post("/auth/password-reset/check", json={"token": token})
|
||||
assert r.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_only_an_admin_can_mint_one(db, as_user, make_user):
|
||||
plain = make_user()
|
||||
victim = make_user()
|
||||
db.commit()
|
||||
|
||||
r = as_user(plain).post(f"/api/admin/users/{victim.id}/set-password-link")
|
||||
assert r.status_code in (401, 403)
|
||||
assert db.query(AuthToken).filter(AuthToken.kind == "setpw").count() == 0
|
||||
|
||||
|
||||
def test_the_shared_demo_account_is_refused(db, as_user, make_user):
|
||||
"""The demo account is shared, so handing anyone a way to change its password hands them
|
||||
everyone else's session on it."""
|
||||
admin = make_user(role="admin")
|
||||
demo = make_user(is_demo=True)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/users/{demo.id}/set-password-link")
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"]["reason"] == "demo"
|
||||
|
||||
|
||||
def test_the_access_panel_reports_the_gates(db, as_user, make_user):
|
||||
admin = make_user(role="admin")
|
||||
target = make_user(email_verified=False, is_active=False)
|
||||
db.commit()
|
||||
|
||||
body = as_user(admin).get(f"/api/admin/users/{target.id}/access").json()
|
||||
assert body["email_verified"] is False
|
||||
assert body["is_active"] is False
|
||||
assert set(body) >= {
|
||||
"is_allowed",
|
||||
"can_activate",
|
||||
"email_configured",
|
||||
"password_revoked_at",
|
||||
"google_email",
|
||||
"has_password",
|
||||
"has_google",
|
||||
}
|
||||
|
||||
|
||||
def test_the_panel_names_a_google_address_only_when_it_differs(db, as_user, make_user):
|
||||
""" "Connected" and "connected as someone else" are different statements, and only the second one
|
||||
explains a confusing sign-in. Repeating the account's own address would be noise on every row."""
|
||||
admin = make_user(role="admin")
|
||||
same = make_user(email="same@example.com")
|
||||
same.google_email = "same@example.com"
|
||||
other = make_user(email="typed@example.com")
|
||||
other.google_email = "google-primary@example.com"
|
||||
db.commit()
|
||||
|
||||
c = as_user(admin)
|
||||
assert c.get(f"/api/admin/users/{same.id}/access").json()["google_email"] is None
|
||||
assert (
|
||||
c.get(f"/api/admin/users/{other.id}/access").json()["google_email"]
|
||||
== "google-primary@example.com"
|
||||
)
|
||||
|
||||
|
||||
def test_the_link_is_audited_without_recording_the_link(db, as_user, make_user):
|
||||
"""The audit log is read by humans and kept long; a live credential-setting link is the last
|
||||
thing that belongs in it. That one was ISSUED, by whom, for whom, is the auditable fact."""
|
||||
from app.models import AuditLog
|
||||
|
||||
admin = make_user(role="admin")
|
||||
target = make_user()
|
||||
db.commit()
|
||||
|
||||
url = as_user(admin).post(f"/api/admin/users/{target.id}/set-password-link").json()["url"]
|
||||
secret = url.split("#reset=", 1)[1]
|
||||
|
||||
rows = db.query(AuditLog).filter(AuditLog.action == "user_setpw_link").all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].summary == target.email
|
||||
assert secret not in str(rows[0].summary) + str(rows[0].before) + str(rows[0].after)
|
||||
|
||||
|
||||
def test_a_google_only_account_can_be_given_a_password(db, as_user, make_user, client):
|
||||
"""Deliberately not gated on the account's state: the admin's real case is whichever one it is,
|
||||
and narrowing this would only make it refuse exactly then."""
|
||||
admin = make_user(role="admin")
|
||||
google_only = make_user(password=None)
|
||||
db.commit()
|
||||
assert google_only.password_hash is None
|
||||
|
||||
token = _link(as_user(admin), google_only.id)
|
||||
r = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "now it has one too"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
db.refresh(google_only)
|
||||
assert google_only.password_hash is not None
|
||||
|
||||
|
||||
def test_an_unknown_user_is_a_404(db, as_user, make_user):
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
assert as_user(admin).post("/api/admin/users/999999/set-password-link").status_code == 404
|
||||
assert as_user(admin).get("/api/admin/users/999999/access").status_code == 404
|
||||
|
||||
|
||||
def test_the_issued_token_is_stored_hashed(db, as_user, make_user):
|
||||
"""Same rule as every other token here: the DB holds a hash, so a database read cannot hand
|
||||
someone a working link."""
|
||||
admin = make_user(role="admin")
|
||||
target = make_user()
|
||||
db.commit()
|
||||
|
||||
raw = _link(as_user(admin), target.id)
|
||||
rows = db.query(AuthToken).filter(AuthToken.kind == "setpw").all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].token_hash != raw
|
||||
assert raw not in rows[0].token_hash
|
||||
|
||||
|
||||
def test_the_link_outlives_the_one_hour_reset_window(db, as_user, make_user):
|
||||
"""It travels out of band — the admin has to actually reach the person — so the mailed reset's
|
||||
one hour is far too short."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
admin = make_user(role="admin")
|
||||
target = make_user()
|
||||
db.commit()
|
||||
|
||||
_link(as_user(admin), target.id)
|
||||
row = db.query(AuthToken).filter(AuthToken.kind == "setpw").one()
|
||||
assert row.expires_at - datetime.now(timezone.utc) > timedelta(hours=2)
|
||||
|
||||
|
||||
def test_users_listing_still_serializes(db, as_user, make_user):
|
||||
"""The list gained two fields; this pins that it did not gain the two per-row QUERIES the panel
|
||||
endpoint exists to keep out of it."""
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
rows = as_user(admin).get("/api/admin/users").json()
|
||||
assert rows and "password_revoked_at" in rows[0] and "google_email" in rows[0]
|
||||
assert "can_activate" not in rows[0]
|
||||
@@ -0,0 +1,99 @@
|
||||
"""SEC-12 · the download URL guard.
|
||||
|
||||
The Download Center hands a user-supplied URL to yt-dlp on the worker, so the server fetches
|
||||
whatever the user typed. These pin that a URL resolving into our own network is refused, that the
|
||||
refusal reasons stay distinct (an unresolvable host is not the same message as a blocked one), and
|
||||
that a normal public URL still goes through.
|
||||
|
||||
DNS is stubbed rather than live: the point under test is the address JUDGEMENT, and a test that
|
||||
resolves real names would be slow, offline-fragile, and would silently start passing if the guard
|
||||
stopped resolving at all.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.downloads import urlguard
|
||||
|
||||
|
||||
def _stub_dns(monkeypatch, addrs):
|
||||
"""Make every lookup answer with `addrs` (a list of textual IPs)."""
|
||||
|
||||
def fake_getaddrinfo(host, port, *a, **kw):
|
||||
return [(None, None, None, "", (addr, port or 0)) for addr in addrs]
|
||||
|
||||
monkeypatch.setattr(urlguard.socket, "getaddrinfo", fake_getaddrinfo)
|
||||
|
||||
|
||||
def _blocked_reason(url):
|
||||
with pytest.raises(urlguard.BlockedUrl) as e:
|
||||
urlguard.check_url(url)
|
||||
return e.value.reason
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"addr",
|
||||
[
|
||||
"127.0.0.1", # loopback
|
||||
"10.1.2.3", # RFC1918
|
||||
"192.168.0.5", # RFC1918
|
||||
"172.16.9.9", # RFC1918
|
||||
"169.254.169.254", # link-local — the cloud metadata endpoint
|
||||
"0.0.0.0", # unspecified
|
||||
"::1", # IPv6 loopback
|
||||
"fe80::1", # IPv6 link-local
|
||||
"fc00::1", # IPv6 unique-local
|
||||
"::ffff:127.0.0.1", # loopback wearing an IPv6 hat
|
||||
],
|
||||
)
|
||||
def test_addresses_on_our_own_network_are_refused(monkeypatch, addr):
|
||||
_stub_dns(monkeypatch, [addr])
|
||||
assert _blocked_reason("http://whatever.example/video") == urlguard.REASON_PRIVATE
|
||||
|
||||
|
||||
def test_one_bad_answer_in_a_round_robin_blocks_the_whole_host(monkeypatch):
|
||||
# A host answering with both a public and an internal address must not become a coin flip:
|
||||
# yt-dlp would pick for itself, so the guard refuses if ANY answer is forbidden.
|
||||
_stub_dns(monkeypatch, ["93.184.216.34", "127.0.0.1"])
|
||||
assert _blocked_reason("https://mixed.example/v") == urlguard.REASON_PRIVATE
|
||||
|
||||
|
||||
def test_a_public_address_passes(monkeypatch):
|
||||
_stub_dns(monkeypatch, ["93.184.216.34"])
|
||||
urlguard.check_url("https://example.com/watch?v=abc") # does not raise
|
||||
|
||||
|
||||
def test_unresolvable_is_its_own_reason(monkeypatch):
|
||||
import socket as _socket
|
||||
|
||||
def boom(*a, **kw):
|
||||
raise _socket.gaierror("nope")
|
||||
|
||||
monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom)
|
||||
# Distinct from PRIVATE on purpose: "we couldn't look that up" and "we refuse to fetch that"
|
||||
# are different things to tell a user, and the frontend renders them differently.
|
||||
assert _blocked_reason("https://nx.example/v") == urlguard.REASON_UNRESOLVABLE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", ["file:///etc/passwd", "ftp://host/x", "gopher://host/1"])
|
||||
def test_non_http_schemes_are_refused_before_any_lookup(monkeypatch, url):
|
||||
def boom(*a, **kw): # a scheme rejection must not have resolved anything first
|
||||
raise AssertionError("DNS was consulted for a non-http scheme")
|
||||
|
||||
monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom)
|
||||
assert _blocked_reason(url) == urlguard.REASON_SCHEME
|
||||
|
||||
|
||||
def test_a_url_without_a_host_is_refused():
|
||||
assert _blocked_reason("http:///just/a/path") == urlguard.REASON_NO_HOST
|
||||
|
||||
|
||||
def test_a_malformed_url_is_a_refusal_not_a_crash(monkeypatch):
|
||||
# Both shapes raise ValueError LATE — `urlparse` on an unclosed bracket, `.port` only when the
|
||||
# property is READ — and `.hostname` happily returns first. Unguarded, either turns a user typo
|
||||
# into a 500 that escapes the caller's `except BlockedUrl` and loses the localized detail this
|
||||
# module exists to produce.
|
||||
_stub_dns(monkeypatch, ["93.184.216.34"])
|
||||
assert _blocked_reason("http://example.com:99999/v.mp4") == urlguard.REASON_MALFORMED
|
||||
assert _blocked_reason("http://example.com:abc/v.mp4") == urlguard.REASON_MALFORMED
|
||||
assert _blocked_reason("http://[::1/v.mp4") == urlguard.REASON_MALFORMED
|
||||
assert _blocked_reason("http://[example.com]/v.mp4") == urlguard.REASON_MALFORMED
|
||||
@@ -53,6 +53,11 @@ services:
|
||||
# Downloaded media. Defaults to a Docker-managed named volume; set DOWNLOAD_HOST_PATH in .env
|
||||
# to a host directory (e.g. one your Plex server reads) to keep the Plex-style tree there.
|
||||
- ${DOWNLOAD_HOST_PATH:-siftlode_downloads}:/downloads
|
||||
# Plex library, read-only. The Plex module streams files itself, so it needs to SEE them: set
|
||||
# PLEX_MEDIA_HOST_PATH in .env to the host directory holding the library (the local side of the
|
||||
# admin "path map", whose default is /data=/plex-media). Left unset it falls back to an empty
|
||||
# named volume — harmless, and the Plex module is off by default.
|
||||
- ${PLEX_MEDIA_HOST_PATH:-siftlode_plex_media}:/plex-media:ro
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
@@ -74,6 +79,9 @@ services:
|
||||
worker:
|
||||
image: git.b1fr0st.eu/peter/siftlode:${IMAGE_TAG:-latest}
|
||||
command: ["python", "-m", "app.worker"]
|
||||
# tini as PID 1: reaps orphaned ffmpeg/yt-dlp grandchildren when the watchdog hard-kills a
|
||||
# download child's process group (the worker reaps its own direct children via subprocess.wait).
|
||||
init: true
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
@@ -93,6 +101,8 @@ services:
|
||||
networks: [internal]
|
||||
volumes:
|
||||
- ${DOWNLOAD_HOST_PATH:-siftlode_downloads}:/downloads
|
||||
# Same read-only Plex library as the API — the worker resolves library paths too.
|
||||
- ${PLEX_MEDIA_HOST_PATH:-siftlode_plex_media}:/plex-media:ro
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
@@ -114,6 +124,8 @@ services:
|
||||
volumes:
|
||||
siftlode_pgdata:
|
||||
siftlode_downloads:
|
||||
# Empty fallback for the Plex media mount when PLEX_MEDIA_HOST_PATH is unset (Plex module off).
|
||||
siftlode_plex_media:
|
||||
|
||||
networks:
|
||||
internal:
|
||||
|
||||
@@ -38,6 +38,11 @@ services:
|
||||
# Downloaded media. Defaults to a Docker-managed named volume; set DOWNLOAD_HOST_PATH in .env
|
||||
# to a host directory (e.g. one your Plex server reads) to keep the Plex-style tree there.
|
||||
- ${DOWNLOAD_HOST_PATH:-downloads}:/downloads
|
||||
# Plex library, read-only. The Plex module streams files itself, so it needs to SEE them: set
|
||||
# PLEX_MEDIA_HOST_PATH in .env to the host directory holding the library (the local side of the
|
||||
# admin "path map", whose default is /data=/plex-media). Left unset it falls back to an empty
|
||||
# named volume — harmless, and the Plex module is off by default.
|
||||
- ${PLEX_MEDIA_HOST_PATH:-plex_media}:/plex-media:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; exit(0 if urllib.request.urlopen('http://localhost:8000/healthz').status == 200 else 1)"]
|
||||
interval: 15s
|
||||
@@ -57,6 +62,9 @@ services:
|
||||
GIT_SHA: ${GIT_SHA:-unknown}
|
||||
BUILD_DATE: ${BUILD_DATE:-}
|
||||
command: ["python", "-m", "app.worker"]
|
||||
# tini as PID 1: reaps orphaned ffmpeg/yt-dlp grandchildren when the watchdog hard-kills a
|
||||
# download child's process group (the worker reaps its own direct children via subprocess.wait).
|
||||
init: true
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD:-siftlode}@db:5432/${POSTGRES_DB:-siftlode}
|
||||
@@ -73,6 +81,8 @@ services:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ${DOWNLOAD_HOST_PATH:-downloads}:/downloads
|
||||
# Same read-only Plex library as the API — the worker resolves library paths too.
|
||||
- ${PLEX_MEDIA_HOST_PATH:-plex_media}:/plex-media:ro
|
||||
restart: unless-stopped
|
||||
|
||||
# PO-token provider sidecar: mints YouTube Proof-of-Origin tokens so the worker beats bot-detection
|
||||
@@ -85,3 +95,5 @@ services:
|
||||
volumes:
|
||||
pgdata:
|
||||
downloads:
|
||||
# Empty fallback for the Plex media mount when PLEX_MEDIA_HOST_PATH is unset (Plex module off).
|
||||
plex_media:
|
||||
|
||||
+188
-7
@@ -115,13 +115,34 @@ DOWNLOAD_HOST_PATH=/mnt/media/youtube
|
||||
|
||||
The directory must be writable by the container user (uid `1000`): `sudo chown -R 1000:1000 <dir>`.
|
||||
|
||||
## Plex library (optional)
|
||||
|
||||
If you run a **Plex** server, Siftlode can browse and play your movies and shows beside your YouTube
|
||||
feed. Two things have to line up:
|
||||
|
||||
1. **Server URL + token**, entered as admin under **Configuration → Plex**. That is how Siftlode reads
|
||||
your libraries.
|
||||
2. **The media files themselves**, mounted into the container read-only. Siftlode streams the files
|
||||
directly rather than proxying Plex, so it has to be able to open them.
|
||||
|
||||
Point `PLEX_MEDIA_HOST_PATH` at the directory holding your library and re-run `up -d`:
|
||||
|
||||
```bash
|
||||
PLEX_MEDIA_HOST_PATH=/mnt/media
|
||||
```
|
||||
|
||||
It appears inside the container at `/plex-media`, read-only. Siftlode then rewrites the paths Plex
|
||||
reports into that mount using the **path map** on the same admin page — its default, `/data=/plex-media`,
|
||||
means "wherever Plex says `/data/movies/…`, look in `/plex-media/movies/…`". Set the left-hand side to
|
||||
whatever prefix your Plex server uses, and mount the matching host directory so the rest of the path
|
||||
still lines up.
|
||||
|
||||
Leave `PLEX_MEDIA_HOST_PATH` unset if you don't use Plex — the mount falls back to an empty volume and
|
||||
the module stays off.
|
||||
|
||||
## Day-to-day
|
||||
|
||||
```bash
|
||||
# Update to the latest release
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
|
||||
# Logs / status (api = web, worker = downloads/edits)
|
||||
docker compose -f docker-compose.selfhost.yml logs -f api
|
||||
docker compose -f docker-compose.selfhost.yml logs -f worker
|
||||
@@ -131,6 +152,166 @@ docker compose -f docker-compose.selfhost.yml ps
|
||||
docker compose -f docker-compose.selfhost.yml down
|
||||
```
|
||||
|
||||
Your data (accounts, subscriptions, playlists, the video catalog) lives in the `siftlode_pgdata`
|
||||
Docker volume — back that up to keep your instance's state. Database migrations run automatically
|
||||
when the app starts, so updating is just pull + up.
|
||||
## Backups
|
||||
|
||||
**A backup is three things, not one.** Copying only the database gets you an instance that starts and
|
||||
then cannot read its own secrets.
|
||||
|
||||
1. **The database** — accounts, subscriptions, playlists, the video catalog, and every setting you
|
||||
made in the admin pages. It lives in the `siftlode_pgdata` Docker volume; dump it with the command
|
||||
below.
|
||||
2. **`.env`** — in particular `TOKEN_ENCRYPTION_KEY`. Your Google client secret, SMTP password, Plex
|
||||
token and every stored refresh token are encrypted with it *inside* the database. Restore a dump
|
||||
against a different key and those values are lost — you would have to clear the affected rows from
|
||||
`system_config` and re-enter every secret by hand. Keep `.env` beside the dump, and just as private.
|
||||
3. **Downloaded media** *(only if you use the Download Center)* — the `siftlode_downloads` volume, or
|
||||
whatever `DOWNLOAD_HOST_PATH` points at. Not needed to bring the instance back; the jobs and their
|
||||
metadata are in the database, the files themselves are not.
|
||||
|
||||
Dump and restore the database with these two commands. They also exist as scripts
|
||||
(`scripts/backup.sh` + `scripts/restore.sh`, or `backup.ps1` / `restore.ps1` on Windows) if you have
|
||||
the source checkout:
|
||||
|
||||
If you customised `POSTGRES_USER` / `POSTGRES_DB` in `.env`, substitute them for `siftlode` below —
|
||||
the scripts read them from the environment, these one-liners cannot.
|
||||
|
||||
```bash
|
||||
# Back up. Write to a .part name and rename only on success: redirecting straight into the final
|
||||
# name creates the file BEFORE pg_dump runs, so a failed dump leaves a plausible-looking 0-byte
|
||||
# file in backups/ that you will not discover until the day you need it.
|
||||
mkdir -p backups && out="backups/siftlode-$(date +%Y%m%d-%H%M%S).dump" && \
|
||||
docker compose -f docker-compose.selfhost.yml exec -T db \
|
||||
pg_dump -U siftlode -Fc siftlode > "$out.part" \
|
||||
&& [ -s "$out.part" ] && mv "$out.part" "$out" && echo "wrote $out" \
|
||||
|| { rm -f "$out.part"; echo "BACKUP FAILED" >&2; false; }
|
||||
|
||||
# Restore. Stop the app first: pg_restore --clean DROPs each object in its own transaction, and a
|
||||
# running api holds locks that make those DROPs queue — abort the wait and you are left with a
|
||||
# half-dropped database. Restoring with the app down also means nothing writes into a schema that
|
||||
# is only partly back.
|
||||
docker compose -f docker-compose.selfhost.yml stop api worker
|
||||
docker compose -f docker-compose.selfhost.yml exec -T db \
|
||||
pg_restore -U siftlode -d siftlode --clean --if-exists < backups/siftlode-<timestamp>.dump
|
||||
|
||||
# Start it again only once the restore reported no errors — and, if you are rolling a release back,
|
||||
# only AFTER pinning the older IMAGE_TAG. Every start runs `alembic upgrade head`, so bringing the
|
||||
# newer image back here would re-apply the migrations you just restored away. Use `up -d`, not
|
||||
# `start`: `start` relaunches the existing container, so it ignores the tag you just pinned and
|
||||
# comes back on the image you are rolling back FROM.
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
On Windows, do **not** pipe the dump through PowerShell — it corrupts binary data. Write it inside the
|
||||
container and copy it out instead. Check the exit code between the two steps and use a timestamped
|
||||
name: the dump inside the container keeps its name, so a `cp` that runs after a *failed* dump would
|
||||
copy out the previous run's file and quietly overwrite your only backup with a stale one.
|
||||
|
||||
Paste it as ONE block. `throw` only aborts the statement it is in, so as separate pasted lines the
|
||||
copy would still run after a failed dump — and copy out the previous run's file:
|
||||
|
||||
```powershell
|
||||
& {
|
||||
# Inside the block, because a scriptblock inherits the caller's preference — under a profile that
|
||||
# sets SilentlyContinue, every `throw` below would be swallowed and the block would run on.
|
||||
$ErrorActionPreference = "Stop"
|
||||
$compose = "docker-compose.selfhost.yml"
|
||||
New-Item -ItemType Directory -Force -Path backups | Out-Null
|
||||
$out = "backups\siftlode-$(Get-Date -Format yyyyMMdd-HHmmss).dump"
|
||||
docker compose -f $compose exec -T db pg_dump -U siftlode -Fc siftlode -f /tmp/s.dump
|
||||
$code = $LASTEXITCODE
|
||||
# Remove the in-container dump on the failure path too: leaving it there is how a LATER failed run
|
||||
# copies out this run's file and overwrites a good backup with a stale one.
|
||||
if ($code -ne 0) { docker compose -f $compose exec -T db rm -f /tmp/s.dump; throw "BACKUP FAILED - pg_dump returned $code" }
|
||||
docker compose -f $compose cp db:/tmp/s.dump $out
|
||||
$code = $LASTEXITCODE
|
||||
docker compose -f $compose exec -T db rm -f /tmp/s.dump
|
||||
# Remove the partial file too: `cp` streams, so a mid-stream failure leaves a truncated dump
|
||||
# under the final name — the plausible-looking bad backup the .part dance above avoids.
|
||||
if ($code -ne 0) { Remove-Item $out -ErrorAction SilentlyContinue; throw "BACKUP FAILED - could not copy the dump out (exit $code)" }
|
||||
if ((Get-Item $out).Length -eq 0) { Remove-Item $out; throw "BACKUP FAILED - the dump came out empty" }
|
||||
"wrote $out"
|
||||
}
|
||||
```
|
||||
|
||||
## Upgrading
|
||||
|
||||
**Take a backup first.** Every start runs any new database migrations automatically, and a migration
|
||||
is not reversible by pulling the old image back — see rollback below.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
That tracks `latest`, which moves whenever a release is cut. **To decide for yourself when you move,
|
||||
pin a version** by putting it in `.env`:
|
||||
|
||||
```bash
|
||||
IMAGE_TAG=0.80.0
|
||||
```
|
||||
|
||||
Then `pull` + `up -d` stays on that version until you change the line. Released versions and what
|
||||
changed in them are listed in the app itself, under **Release Notes** in the account menu, and in
|
||||
[`CHANGELOG.md`](../CHANGELOG.md).
|
||||
|
||||
**The registry keeps only the newest few releases, not every version the changelog lists.** A pin is
|
||||
"decide when you take the next hop", not "stay here forever": once you are several releases behind,
|
||||
your pinned tag can no longer be pulled — which matters the day you rebuild the host, prune your
|
||||
Docker images, or move to a new machine. A container that is already running is unaffected (Docker
|
||||
keeps the image locally), and you can always go back to `latest` or build the image yourself from the
|
||||
repository. If you want to sit on one version long term, keep your own copy:
|
||||
`docker save git.b1fr0st.eu/peter/siftlode:0.80.0 -o siftlode-0.80.0.tar`.
|
||||
|
||||
### Rollback
|
||||
|
||||
If a release misbehaves, set `IMAGE_TAG` back to the previous version and `up -d`. That is enough
|
||||
**as long as the release did not change the database schema**. If it did, the older image will refuse
|
||||
to start against the newer schema.
|
||||
|
||||
**Restoring your pre-upgrade dump is the recommended way out of that** — it puts the database back
|
||||
exactly where the old image expects it, in one step, with no revision arithmetic:
|
||||
|
||||
```bash
|
||||
# 1. stop the app (leave the database up)
|
||||
docker compose -f docker-compose.selfhost.yml stop api worker
|
||||
# 2. restore the dump you took before upgrading (see Backups above). Leave the app stopped:
|
||||
# starting it here would run the NEWER image's migrations against the database you just restored.
|
||||
docker compose -f docker-compose.selfhost.yml exec -T db \
|
||||
pg_restore -U siftlode -d siftlode --clean --if-exists < backups/siftlode-<timestamp>.dump
|
||||
# 3. pin the previous version in .env, then start again
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
If you have no usable dump, you can step the schema back instead — but **not with `alembic downgrade
|
||||
-1`**. One release routinely ships several migrations (0.80.0 shipped four), so `-1` undoes only the
|
||||
newest one and leaves the schema at a revision the older image *also* cannot resolve: you lose that
|
||||
migration's data and still get a crash loop. Downgrade to the revision the target version expects,
|
||||
which that version can tell you itself:
|
||||
|
||||
```bash
|
||||
# 1. stop the app (leave the database up)
|
||||
docker compose -f docker-compose.selfhost.yml stop api worker
|
||||
# 2. ask the version you are rolling back TO which schema revision it expects
|
||||
IMAGE_TAG=0.79.0 docker compose -f docker-compose.selfhost.yml run --rm api alembic heads
|
||||
# 3. step the schema back to exactly that revision — run this with the CURRENT (newer) image,
|
||||
# because it is the one that ships the migrations being undone
|
||||
docker compose -f docker-compose.selfhost.yml run --rm api alembic downgrade <revision from step 2>
|
||||
# 4. pin the previous version in .env, then start again
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
A downgrade drops the columns the newer version added, so anything stored only in those columns is
|
||||
gone. That is the price of not having the dump.
|
||||
|
||||
### A migration failed on start
|
||||
|
||||
The container will keep restarting (`restart: unless-stopped`), and `worker` will sit waiting for an
|
||||
`api` that never becomes healthy. Read the reason first:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml logs api | tail -50
|
||||
```
|
||||
|
||||
Then either fix the cause and let it retry, or roll back: restore your pre-upgrade dump, pin the
|
||||
previous `IMAGE_TAG`, and `up -d`. Do not delete the `siftlode_pgdata` volume to "start clean" unless
|
||||
you have a dump you have actually tested restoring — that volume *is* your instance.
|
||||
|
||||
@@ -67,5 +67,47 @@ export default tseslint.config(
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// E10 S1: `components/ui/` is the PLATFORM layer, and the rule that makes it a layer rather than
|
||||
// a folder is enforced here, not in prose (see components/ui/README.md). A platform component is
|
||||
// domain-free: it may import sibling primitives and generic `lib/` helpers, never a feature
|
||||
// component (`../Feed`) and never a domain API slice. Without this rule the boundary erodes on
|
||||
// the first "just this once" import and the directory silently becomes another components/.
|
||||
files: ["src/components/ui/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
// NOTE the String.raw: these are REGEX sources, and in a plain "..." literal `\.` is an
|
||||
// unknown escape that collapses to a bare `.` — the any-char wildcard. The first cut of
|
||||
// this rule shipped that way and matched far less than it read like (E10 review round 1).
|
||||
patterns: [
|
||||
{
|
||||
// `../X` (exactly one level up = a feature component). A `regex` rather than a glob:
|
||||
// eslint matches `patterns` gitignore-style, where `../*` also swallows `../../lib/x`.
|
||||
regex: String.raw`^\.\./(?!\.\./)`,
|
||||
message:
|
||||
"components/ui/ is domain-free: a platform component may not import a feature component. Invert the dependency (take a prop/render-prop) or keep the component outside ui/.",
|
||||
},
|
||||
{
|
||||
// The same file by its OTHER spelling. `../../components/Feed` resolves exactly where
|
||||
// `../Feed` does, so the rule above is only half the boundary; `ui/` is carved out
|
||||
// because a sibling primitive is the one thing this path may legitimately name.
|
||||
regex: String.raw`^\.\./\.\./components/(?!ui/)`,
|
||||
message:
|
||||
"components/ui/ is domain-free: a platform component may not import a feature component. Invert the dependency (take a prop/render-prop) or keep the component outside ui/.",
|
||||
},
|
||||
{
|
||||
// `(/|$)` so the BARREL counts too: `../../lib/api` is the spelling every feature
|
||||
// component uses, and a `/`-anchored pattern let exactly that one through.
|
||||
regex: String.raw`^\.\./\.\./lib/api(/|$)`,
|
||||
message:
|
||||
"components/ui/ is domain-free: a platform component may not call a domain API slice. Take the data as props.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
prettier,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "siftlode-frontend",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+17
-4
@@ -29,12 +29,15 @@ import Welcome from "./components/Welcome";
|
||||
import SetupWizard from "./components/SetupWizard";
|
||||
import Header from "./components/Header";
|
||||
import NavSidebar from "./components/NavSidebar";
|
||||
import BackToTop from "./components/BackToTop";
|
||||
import PageShell from "./components/PageShell";
|
||||
import BackToTop from "./components/ui/BackToTop";
|
||||
import PageShell from "./components/ui/PageShell";
|
||||
import GlassTuner from "./components/GlassTuner";
|
||||
import ChatDock from "./components/ChatDock";
|
||||
import Toaster from "./components/Toaster";
|
||||
import { StateMessage } from "./components/QueryState";
|
||||
import Toaster from "./components/ui/Toaster";
|
||||
import KeyboardHelp from "./components/ui/KeyboardHelp";
|
||||
import AppCommandPalette from "./components/AppCommandPalette";
|
||||
import { useGlobalKeymap } from "./lib/keymap";
|
||||
import { StateMessage } from "./components/ui/QueryState";
|
||||
import ErrorDialog from "./components/ErrorDialog";
|
||||
import VersionBanner from "./components/VersionBanner";
|
||||
import DemoBanner from "./components/DemoBanner";
|
||||
@@ -134,6 +137,11 @@ export default function App() {
|
||||
refetchInterval: (query) => (query.state.data ? 60_000 : false),
|
||||
});
|
||||
|
||||
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette. Bound only once
|
||||
// signed in, because that is where both overlays are mounted (bottom of this tree) — see
|
||||
// useGlobalKeymap on why binding it above the signed-out branch is worse than not binding it.
|
||||
useGlobalKeymap(!!meQuery.data);
|
||||
|
||||
// Pin this tab to whatever account it first loaded as. Without this, a tab that never picked
|
||||
// an account (it was riding the session's default) would silently swap identity when ANOTHER
|
||||
// tab changes the default — e.g. that other tab adds/switches an account. Once pinned, this
|
||||
@@ -412,6 +420,11 @@ export default function App() {
|
||||
(collapsed or expanded) without tracking its width. */}
|
||||
<Toaster />
|
||||
</div>
|
||||
{/* The two app-wide overlays: the keyboard cheat sheet (`h`) and the jump-to palette (Mod+K).
|
||||
Mounted once, outside the page area, so every surface answers "what can I press here" and
|
||||
"take me to X" the same way. Their keys are bound by useGlobalKeymap above. */}
|
||||
<KeyboardHelp />
|
||||
<AppCommandPalette />
|
||||
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
|
||||
<Suspense fallback={null}>
|
||||
{aboutOpen && (
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { Info, Sparkles } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { FRONTEND_VERSION, FRONTEND_SHA, FRONTEND_BUILD_DATE } from "../lib/version";
|
||||
import Modal from "./Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
|
||||
function fmtDate(d?: string | null): string {
|
||||
if (!d) return "—";
|
||||
@@ -70,12 +71,9 @@ export default function About({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onOpenReleaseNotes}
|
||||
className="mt-4 w-full inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
|
||||
>
|
||||
<Button variant="primary" size="lg" className="mt-4 w-full" onClick={onOpenReleaseNotes}>
|
||||
<Sparkles className="w-4 h-4" /> {t("about.whatsNew")}
|
||||
</button>
|
||||
</Button>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api } from "../lib/api";
|
||||
import ChannelAboutContent from "./ChannelAbout";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
/**
|
||||
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Clock, Copy, Globe, KeyRound, Mail, Send, ShieldAlert } from "lucide-react";
|
||||
import Modal from "./ui/Modal";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
|
||||
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
||||
|
||||
// The admin's answer to "why can this person not get in?", and the place every recovery action for
|
||||
// one account lives (E12 · S5, design option C).
|
||||
//
|
||||
// It exists because nothing in the admin UI showed an account's GATE STATE: an admin could see a
|
||||
// "Pending approval" chip but not whether the address was confirmed, whether the password had been
|
||||
// cleared by SEC-10, or which Google account was attached. Every support conversation started by
|
||||
// guessing. The recovery actions sit under that readout rather than in the row, so the admin picks
|
||||
// one having read what is actually wrong.
|
||||
|
||||
/** One line of the Access readout: an icon, a label, and the state in the tone that state deserves. */
|
||||
function AccessRow({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
tone = "muted",
|
||||
}: {
|
||||
icon: typeof Mail;
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "muted" | "warn" | "ok";
|
||||
}) {
|
||||
const toneCls = tone === "warn" ? "text-warn" : tone === "ok" ? "text-success" : "text-muted";
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Icon className={`w-3.5 h-3.5 shrink-0 ${toneCls}`} />
|
||||
<span className="text-muted w-28 shrink-0">{label}</span>
|
||||
<span className={`${toneCls} min-w-0 truncate`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccountPanel({
|
||||
user,
|
||||
onClose,
|
||||
}: {
|
||||
user: AdminUserRow;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [link, setLink] = useState<{ url: string; expires_at: string | null } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: qk.adminUserAccess(user.id),
|
||||
queryFn: () => api.adminUserAccess(user.id),
|
||||
});
|
||||
|
||||
// Every action here goes through a `quiet: true` endpoint, which suppresses the GLOBAL error
|
||||
// dialog on the promise that the caller reports the failure inline. Without an `onError` that
|
||||
// promise is broken and a refusal renders as nothing at all — on the broken-SMTP instance this
|
||||
// panel exists for, the admin would read silence as "the mail went out".
|
||||
const issue = useMutation({
|
||||
mutationFn: () => api.createSetPasswordLink(user.id),
|
||||
onSuccess: (r) => setLink({ url: r.url, expires_at: r.expires_at }),
|
||||
onError: () => notify({ level: "error", message: t("users.account.linkFailed") }),
|
||||
});
|
||||
|
||||
const resend = useMutation({
|
||||
mutationFn: () => api.resendUserVerification(user.id),
|
||||
onSuccess: () => notify({ level: "success", message: t("users.account.resendSent") }),
|
||||
onError: (err) =>
|
||||
notify({
|
||||
level: "error",
|
||||
message: isEmailOff(err)
|
||||
? t("users.pendingAccess.resendEmailOff")
|
||||
: t("users.pendingAccess.resendFailed"),
|
||||
}),
|
||||
});
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: () => api.activateUser(user.id),
|
||||
onSuccess: () => invalidateUserAccess(qc, user.id),
|
||||
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
|
||||
});
|
||||
|
||||
const copy = async () => {
|
||||
if (!link) return;
|
||||
// Via the shared helper, whose synchronous `execCommand` path is what makes copying work on a
|
||||
// plain-http LAN origin — where `navigator.clipboard` is undefined, and which is exactly the
|
||||
// minimal self-host this no-email link was built for.
|
||||
if (await copyText(link.url)) {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
return;
|
||||
}
|
||||
// The link is on screen and selectable, so a failed copy is a convenience that failed — not the
|
||||
// action failing. Say so rather than leaving the button looking inert.
|
||||
notify({ level: "warning", message: t("users.account.copyFailed") });
|
||||
};
|
||||
|
||||
const a: AdminUserAccess | undefined = q.data;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={user.display_name ?? user.email.split("@")[0]}
|
||||
onClose={onClose}
|
||||
placement="drawer"
|
||||
maxWidth="max-w-md"
|
||||
>
|
||||
<div className="text-2xs text-muted truncate -mt-2 mb-4">{user.email}</div>
|
||||
|
||||
{q.isError && !a ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : !a ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
|
||||
{t("users.account.access")}
|
||||
</div>
|
||||
<div className="glass-card rounded-xl p-3 space-y-2 mb-5">
|
||||
<AccessRow
|
||||
icon={Mail}
|
||||
label={t("users.account.email")}
|
||||
value={
|
||||
a.email_verified
|
||||
? t("users.account.emailConfirmed")
|
||||
: t("users.account.emailUnconfirmed")
|
||||
}
|
||||
tone={a.email_verified ? "ok" : "warn"}
|
||||
/>
|
||||
<AccessRow
|
||||
icon={Clock}
|
||||
label={t("users.account.approval")}
|
||||
value={
|
||||
a.is_active
|
||||
? t("users.account.approvalDone")
|
||||
: a.can_activate
|
||||
? t("users.account.approvalWaitingOnYou")
|
||||
: t("users.account.approvalNotRequested")
|
||||
}
|
||||
tone={a.is_active ? "ok" : "warn"}
|
||||
/>
|
||||
<AccessRow
|
||||
icon={KeyRound}
|
||||
label={t("users.account.password")}
|
||||
value={
|
||||
a.has_password
|
||||
? t("users.account.passwordSet")
|
||||
: a.password_revoked_at
|
||||
? t("users.account.passwordRevoked")
|
||||
: t("users.account.passwordNone")
|
||||
}
|
||||
// Not having a password is only a PROBLEM when it was taken away; a Google-only
|
||||
// account never had one and is working exactly as intended.
|
||||
tone={a.has_password ? "ok" : a.password_revoked_at ? "warn" : "muted"}
|
||||
/>
|
||||
<AccessRow
|
||||
icon={Globe}
|
||||
label={t("users.account.google")}
|
||||
value={
|
||||
!a.has_google
|
||||
? t("users.account.googleNone")
|
||||
: a.google_email
|
||||
? t("users.account.googleAs", { email: a.google_email })
|
||||
: t("users.account.googleConnected")
|
||||
}
|
||||
tone={a.has_google ? "ok" : "muted"}
|
||||
/>
|
||||
{/* The gate that explains a Google sign-in silently becoming a fresh access request. */}
|
||||
{!a.is_allowed && (
|
||||
<AccessRow
|
||||
icon={ShieldAlert}
|
||||
label={t("users.account.invite")}
|
||||
value={t("users.account.inviteMissing")}
|
||||
tone="warn"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
|
||||
{t("users.account.recovery")}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{a.can_activate && (
|
||||
<button
|
||||
onClick={() => activate.mutate()}
|
||||
disabled={activate.isPending}
|
||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
|
||||
>
|
||||
<Check className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 text-left">{t("users.account.activate")}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => resend.mutate()}
|
||||
disabled={!a.email_configured || a.email_verified || resend.isPending}
|
||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs disabled:opacity-45 transition"
|
||||
>
|
||||
<Send className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 text-left">{t("users.account.resend")}</span>
|
||||
{/* Says WHY it is unavailable instead of just greying out — the no-mail case is the
|
||||
whole reason the link below exists, so naming it here is the explanation. */}
|
||||
{!a.email_configured && (
|
||||
<span className="text-muted">{t("users.account.mailOff")}</span>
|
||||
)}
|
||||
{a.email_configured && a.email_verified && (
|
||||
<span className="text-muted">{t("users.account.alreadyConfirmed")}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => issue.mutate()}
|
||||
disabled={issue.isPending || user.is_demo}
|
||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
|
||||
>
|
||||
<KeyRound className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 text-left">
|
||||
{link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{link && (
|
||||
<div className="mt-4 pt-4 border-t border-border space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-3 py-2 text-2xs font-mono truncate">
|
||||
{link.url}
|
||||
</div>
|
||||
<button
|
||||
onClick={copy}
|
||||
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs border border-accent/40 text-accent transition"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? t("users.account.copied") : t("users.account.copy")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-2xs text-muted leading-relaxed">{t("users.account.linkNote")}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
|
||||
// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
|
||||
// and LanguageSwitcher. Use it to keep occasional actions out of a crowded toolbar. The items are
|
||||
// fire-and-close (`onSelect`); a `disabled` item (e.g. a mutation already in flight) is inert.
|
||||
|
||||
export interface ActionMenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode; // an element (so a caller can swap in a spinning icon while pending)
|
||||
onSelect: () => void;
|
||||
disabled?: boolean;
|
||||
title?: string; // optional native tooltip (carries a longer hint the label alone can't)
|
||||
}
|
||||
|
||||
export default function ActionMenu({
|
||||
items,
|
||||
ariaLabel,
|
||||
className,
|
||||
trigger,
|
||||
}: {
|
||||
items: readonly ActionMenuItem[];
|
||||
/** Accessible name for the trigger (e.g. "Catalog actions"). */
|
||||
ariaLabel: string;
|
||||
/** Trigger className override; omit for the default glass ⋯ icon button. */
|
||||
className?: string;
|
||||
/** Custom trigger content; omit for the default ⋯ icon. */
|
||||
trigger?: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// A disabled item (a mutation already in flight) is skipped by roving keyboard nav automatically:
|
||||
// PopoverItem renders it as a native `disabled` button, which useListNavigation reads and never
|
||||
// focuses. No explicit disabledIndices needed. See ActionMenu.dom.test.tsx for the guard.
|
||||
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" });
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
className={
|
||||
className ??
|
||||
"glass-card glass-hover flex items-center justify-center rounded-xl p-2 text-sm transition"
|
||||
}
|
||||
>
|
||||
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
|
||||
</button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={ariaLabel}
|
||||
className="glass-menu min-w-52 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{items.map((it) => (
|
||||
<PopoverItem
|
||||
key={it.id}
|
||||
role="menuitem"
|
||||
disabled={it.disabled}
|
||||
label={it.label}
|
||||
title={it.title}
|
||||
onSelect={() => {
|
||||
it.onSelect();
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm outline-hidden transition disabled:opacity-50"
|
||||
>
|
||||
{it.icon && <span className="shrink-0 text-muted">{it.icon}</span>}
|
||||
<span className="flex-1 truncate">{it.label}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { Filter, X } from "lucide-react";
|
||||
import type { ActiveFilter } from "../lib/useActiveFilters";
|
||||
import { decideChipLayout } from "../lib/chipOverflow";
|
||||
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
|
||||
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
|
||||
@@ -172,7 +172,7 @@ export default function ActiveFilterChips({
|
||||
<div
|
||||
ref={laneRef}
|
||||
data-testid="active-filter-chips"
|
||||
className={`relative flex min-w-0 items-center gap-1.5 ${className ?? ""}`}
|
||||
className={`${chipLaneClass} flex min-w-0 items-center gap-1.5 ${className ?? ""}`}
|
||||
aria-label={t("feed.chips.label")}
|
||||
>
|
||||
{/* Hidden mirror of the full set, used only to measure natural widths. Kept out of the a11y tree
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Check, ListPlus, Plus, Youtube } from "lucide-react";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
|
||||
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
|
||||
|
||||
@@ -24,16 +24,19 @@ import {
|
||||
type PendingAccessRow,
|
||||
} from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
|
||||
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
||||
import Tooltip from "./Tooltip";
|
||||
import InlineNotice from "./InlineNotice";
|
||||
import { Button } from "./ui/Button";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import InlineNotice from "./ui/InlineNotice";
|
||||
import { Section } from "./ui/form";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { LANGUAGES, type LangCode } from "../i18n";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import AccountPanel from "./AccountPanel";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import Tabs, { usePersistedTab } from "./Tabs";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import Tabs, { usePersistedTab } from "./ui/Tabs";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useMe } from "../lib/useMe";
|
||||
|
||||
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
|
||||
@@ -107,6 +110,9 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
// Which rows have an action in flight, so we disable ONLY those rows' buttons (SC1) — a Set, since
|
||||
// with per-row disabling the admin can now start actions on several rows concurrently.
|
||||
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set());
|
||||
// The account panel (E12 S5): which user it is showing, or null. Held here rather than per row so
|
||||
// only one is ever mounted.
|
||||
const [panelFor, setPanelFor] = useState<AdminUserRow | null>(null);
|
||||
const startPending = (id: number) => setPendingIds((s) => new Set(s).add(id));
|
||||
const endPending = (id: number) =>
|
||||
setPendingIds((s) => {
|
||||
@@ -192,6 +198,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
|
||||
return (
|
||||
<Section card title={t("users.roles.title")}>
|
||||
{panelFor && <AccountPanel user={panelFor} onClose={() => setPanelFor(null)} />}
|
||||
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
@@ -207,13 +214,17 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
const self = u.id === me.id;
|
||||
return (
|
||||
<div key={u.id} className="glass-card flex items-center gap-2.5 p-2.5 rounded-xl">
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
onClick={() => setPanelFor(u)}
|
||||
title={t("users.account.open")}
|
||||
className="selectable cursor-pointer min-w-0 flex-1 text-left rounded-lg -m-1 p-1"
|
||||
>
|
||||
<div className="text-sm truncate">
|
||||
{u.display_name ?? u.email.split("@")[0]}
|
||||
{self && <span className="text-muted"> · {t("users.roles.you")}</span>}
|
||||
</div>
|
||||
<div className="text-2xs text-muted truncate">{u.email}</div>
|
||||
</div>
|
||||
</button>
|
||||
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
|
||||
{u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>}
|
||||
{u.is_suspended && <Badge tone="warning">{t("users.roles.suspended")}</Badge>}
|
||||
@@ -234,14 +245,16 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
: t("users.roles.makeAdmin")
|
||||
}
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
variant="glass"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
onClick={() => onToggle(u)}
|
||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-40 transition"
|
||||
icon={<Shield className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
hint={
|
||||
@@ -254,36 +267,36 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
: t("users.suspend.action")
|
||||
}
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
variant={u.is_suspended ? "outline-warn" : "outline"}
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onSuspend(u)}
|
||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||
aria-label={
|
||||
u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")
|
||||
}
|
||||
className={`shrink-0 p-1.5 rounded-lg border disabled:opacity-40 transition ${
|
||||
u.is_suspended
|
||||
? "border-warn/40 text-warn hover:bg-warn/10"
|
||||
: "border-border text-muted hover:text-warn"
|
||||
}`}
|
||||
>
|
||||
{u.is_suspended ? (
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
) : (
|
||||
<Ban className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
icon={
|
||||
u.is_suspended ? (
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
) : (
|
||||
<Ban className="w-4 h-4" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onDelete(u)}
|
||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||
aria-label={t("users.delete.action")}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-40 transition"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
@@ -316,12 +329,9 @@ function PendingAccess() {
|
||||
queryKey: qk.adminPendingAccess(),
|
||||
queryFn: () => api.adminPendingAccess(),
|
||||
});
|
||||
// Activating changes /api/me's badge count too, so refresh both or the number lies until reload.
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.adminPendingAccess() });
|
||||
qc.invalidateQueries({ queryKey: qk.me() });
|
||||
qc.invalidateQueries({ queryKey: qk.adminUsers() });
|
||||
};
|
||||
// Activating changes /api/me's badge count too, so refresh all of them or the number lies until
|
||||
// reload. Shared with the account panel's copy of this mutation (see lib/adminUsers).
|
||||
const refresh = () => invalidateUserAccess(qc);
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: (u: PendingAccessRow) => api.activateUser(u.id),
|
||||
@@ -343,10 +353,9 @@ function PendingAccess() {
|
||||
onError: (err) =>
|
||||
notify({
|
||||
level: "error",
|
||||
message:
|
||||
err instanceof HttpError && isEmailOff(err)
|
||||
? t("users.pendingAccess.resendEmailOff")
|
||||
: t("users.pendingAccess.resendFailed"),
|
||||
message: isEmailOff(err)
|
||||
? t("users.pendingAccess.resendEmailOff")
|
||||
: t("users.pendingAccess.resendFailed"),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -380,12 +389,6 @@ function PendingAccess() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Did the resend fail because this instance has no working SMTP? */
|
||||
function isEmailOff(err: HttpError): boolean {
|
||||
const d = err.detailData as { code?: string; reason?: string } | null | undefined;
|
||||
return d?.code === "resend" && d.reason === "email_off";
|
||||
}
|
||||
|
||||
// One stuck sign-up. The row names WHICH gate is shut rather than just offering buttons — the same
|
||||
// reasoning as the sign-in card's blocked panel, from the admin's side: an admin about to let someone
|
||||
// in should see that the address has not been confirmed yet, because on a no-SMTP instance that is
|
||||
@@ -435,25 +438,28 @@ function PendingAccessRowView({
|
||||
</div>
|
||||
{!row.email_verified && (
|
||||
<Tooltip hint={t("users.pendingAccess.resendHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onResend}
|
||||
disabled={busy}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-fg disabled:opacity-50 transition"
|
||||
aria-label={t("users.pendingAccess.resend")}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Send className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{row.can_activate && (
|
||||
<Tooltip hint={t("users.pendingAccess.activateHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-accent"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
onClick={onActivate}
|
||||
disabled={busy}
|
||||
className="shrink-0 px-2.5 py-1.5 rounded-lg border border-accent/40 text-accent text-xs font-medium hover:bg-accent/10 disabled:opacity-50 transition"
|
||||
>
|
||||
{t("users.pendingAccess.activate")}
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
@@ -673,12 +679,14 @@ function AdminInvites() {
|
||||
placeholder={t("settings.invites.addPlaceholder")}
|
||||
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
||||
variant="glass"
|
||||
className="shrink-0"
|
||||
icon={<UserPlus className="w-4 h-4" />}
|
||||
>
|
||||
<UserPlus className="w-4 h-4" /> {t("settings.invites.add")}
|
||||
</button>
|
||||
{t("settings.invites.add")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{decided.length > 0 && (
|
||||
@@ -820,14 +828,15 @@ function AdminDemo() {
|
||||
)}
|
||||
</div>
|
||||
<Tooltip hint={t("settings.demo.removeHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onRemove(r)}
|
||||
disabled={remove.isPending}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
||||
aria-label={t("settings.demo.remove")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
@@ -848,12 +857,9 @@ function AdminDemo() {
|
||||
placeholder={t("settings.demo.addPlaceholder")}
|
||||
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
||||
>
|
||||
<Button variant="glass" size="md" className="shrink-0" type="submit">
|
||||
<UserPlus className="w-4 h-4" /> {t("settings.demo.add")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-border">
|
||||
@@ -909,24 +915,26 @@ function InviteRow({
|
||||
)}
|
||||
</div>
|
||||
<Tooltip hint={t("settings.invites.approveHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-accent"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onApprove}
|
||||
disabled={busy}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-accent/40 text-accent hover:bg-accent/10 disabled:opacity-50 transition"
|
||||
aria-label={t("settings.invites.approve")}
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Check className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip hint={t("settings.invites.denyHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onDeny}
|
||||
disabled={busy}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
||||
aria-label={t("settings.invites.deny")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<X className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
{/* Deny records a decision and keeps the row; delete forgets the address. Once an account
|
||||
exists for it the row is that account's approval gate, so it is managed from Users & roles
|
||||
@@ -946,14 +954,15 @@ function InviteRow({
|
||||
</button>
|
||||
) : (
|
||||
<Tooltip hint={t("settings.invites.deleteHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onDelete}
|
||||
disabled={busy}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
||||
aria-label={t("settings.invites.deleteLabel", { email: inv.email })}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Keyboard, ListVideo, Palette, Tv } from "lucide-react";
|
||||
import CommandPalette, { type CommandGroup } from "./ui/CommandPalette";
|
||||
import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { moduleDef, moduleOrder } from "../lib/modules";
|
||||
import { SCHEMES, type Scheme } from "../lib/theme";
|
||||
import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap";
|
||||
import { useNavigationActions } from "./NavigationProvider";
|
||||
import { usePlaylistsActions } from "./PlaylistsProvider";
|
||||
import { usePrefs } from "./PrefsProvider";
|
||||
import { useSavedViews } from "./SavedViewsProvider";
|
||||
|
||||
// What Ctrl/Cmd+K can reach. The generic list/filter/keyboard shell is `ui/CommandPalette`; this is
|
||||
// the domain half — the layer boundary says a primitive may not import `lib/api` or a module.
|
||||
//
|
||||
// Scope decided with the product owner (2026-09-05): navigation + app-wide ACTIONS. The actions here
|
||||
// are the reversible, local ones — the shortcuts sheet and the theme. Deliberately NOT here: "Sync
|
||||
// from YouTube". It is a long-running job against a quota, it wants the progress and error surface
|
||||
// the Channels page already gives it, and one mistyped Enter is a worse failure than the walk it
|
||||
// would have saved.
|
||||
//
|
||||
// Saved views come from whichever module owns the current SavedViewsProvider source (feed on the
|
||||
// feed, Plex on Plex), i.e. exactly the views that are applicable where you stand.
|
||||
export default function AppCommandPalette() {
|
||||
const { t } = useTranslation();
|
||||
const open = usePaletteOpen();
|
||||
const me = useMe();
|
||||
const { setPage, openChannel } = useNavigationActions();
|
||||
const { setSelectedId } = usePlaylistsActions();
|
||||
const { theme, setTheme } = usePrefs();
|
||||
const savedViews = useSavedViews();
|
||||
|
||||
// Only fetch the lists while the palette is actually open — it is a rarely-opened surface and
|
||||
// these are otherwise the feed's and the playlist rail's own queries, shared through the cache.
|
||||
const channelsQ = useQuery({
|
||||
queryKey: qk.channels(),
|
||||
queryFn: () => api.channels(),
|
||||
enabled: open,
|
||||
});
|
||||
const playlistsQ = useQuery({
|
||||
queryKey: qk.playlists(),
|
||||
queryFn: () => api.playlists(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const groups = useMemo<CommandGroup[]>(() => {
|
||||
const out: CommandGroup[] = [];
|
||||
|
||||
// Modules, in nav-rail order and gated by the account — the registry decides, not this file.
|
||||
if (me) {
|
||||
const pages = [...moduleOrder(me), "settings" as const];
|
||||
out.push({
|
||||
id: "modules",
|
||||
label: t("palette.groups.modules"),
|
||||
items: pages.map((page) => {
|
||||
const def = moduleDef(page);
|
||||
const Icon = def.icon;
|
||||
return {
|
||||
id: `page:${page}`,
|
||||
label: t(def.labelKey),
|
||||
icon: <Icon className="w-4 h-4" />,
|
||||
// The longer tab title is a second name for the same place ("Channel manager" for
|
||||
// "Channels"), so match on it even though the row shows the short one.
|
||||
keywords: def.titleKey ? t(def.titleKey) : "",
|
||||
run: () => setPage(page),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const channels = channelsQ.data ?? [];
|
||||
if (channels.length > 0) {
|
||||
out.push({
|
||||
id: "channels",
|
||||
label: t("palette.groups.channels"),
|
||||
items: channels
|
||||
.filter((c) => !c.hidden)
|
||||
.map((c) => ({
|
||||
id: `channel:${c.id}`,
|
||||
label: c.title ?? c.id,
|
||||
hint: c.handle ?? undefined,
|
||||
icon: <Tv className="w-4 h-4" />,
|
||||
keywords: c.handle ?? "",
|
||||
run: () => openChannel(c.id, c.title ?? undefined),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const playlists = playlistsQ.data ?? [];
|
||||
if (playlists.length > 0) {
|
||||
out.push({
|
||||
id: "playlists",
|
||||
label: t("palette.groups.playlists"),
|
||||
items: playlists.map((p) => ({
|
||||
id: `playlist:${p.id}`,
|
||||
label: p.name,
|
||||
hint: t("palette.videoCount", { count: p.item_count }),
|
||||
icon: <ListVideo className="w-4 h-4" />,
|
||||
run: () => {
|
||||
setSelectedId(p.id);
|
||||
setPage("playlists");
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (savedViews.views.length > 0) {
|
||||
out.push({
|
||||
id: "views",
|
||||
label: t("palette.groups.views"),
|
||||
items: savedViews.views.map((v) => ({
|
||||
id: `view:${v.id}`,
|
||||
label: v.name,
|
||||
run: () => savedViews.apply(v),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
out.push({
|
||||
id: "actions",
|
||||
label: t("palette.groups.actions"),
|
||||
items: [
|
||||
{
|
||||
id: "action:shortcuts",
|
||||
label: t("keys.title"),
|
||||
icon: <Keyboard className="w-4 h-4" />,
|
||||
run: toggleKeyboardHelp,
|
||||
},
|
||||
...SCHEMES.map((s) => ({
|
||||
id: `action:theme:${s.id}`,
|
||||
label: t("palette.theme", { name: s.name }),
|
||||
icon: <Palette className="w-4 h-4" />,
|
||||
keywords: s.id,
|
||||
run: () => setTheme({ ...theme, scheme: s.id as Scheme }),
|
||||
})),
|
||||
],
|
||||
});
|
||||
|
||||
return out;
|
||||
}, [
|
||||
t,
|
||||
me,
|
||||
channelsQ.data,
|
||||
playlistsQ.data,
|
||||
savedViews,
|
||||
theme,
|
||||
setTheme,
|
||||
setPage,
|
||||
openChannel,
|
||||
setSelectedId,
|
||||
]);
|
||||
|
||||
return <CommandPalette open={open} onClose={closePalette} groups={groups} />;
|
||||
}
|
||||
@@ -6,10 +6,11 @@ import { Trash2 } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { LS } from "../lib/storage";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Section } from "./ui/form";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import DataTable from "./DataTable";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import DataTable from "./ui/DataTable";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { auditColumns } from "./auditColumns";
|
||||
|
||||
// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run
|
||||
@@ -72,14 +73,15 @@ export default function AuditLog() {
|
||||
persistKey={LS.auditTable}
|
||||
controlsPosition="top"
|
||||
controlsLeading={
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="xs"
|
||||
onClick={onClear}
|
||||
disabled={clear.isPending}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
||||
icon={<Trash2 className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{t("audit.clear")}
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
emptyText={t("audit.empty")}
|
||||
/>
|
||||
|
||||
@@ -12,15 +12,15 @@ import { useCardPager } from "../lib/useCardPager";
|
||||
import { subsColumn } from "./channelColumns";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import Tooltip from "./Tooltip";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import AboutCell from "./AboutCell";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
import DataTable, { type Column } from "./ui/DataTable";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import ColumnSortControl from "./ColumnSortControl";
|
||||
import Pager from "./Pager";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import ColumnSortControl from "./ui/ColumnSortControl";
|
||||
import Pager from "./ui/Pager";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { useChannelLayout } from "./ChannelsProvider";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Column } from "./DataTable";
|
||||
import VirtualGrid from "./VirtualGrid";
|
||||
import { type Column } from "./ui/DataTable";
|
||||
import VirtualGrid from "./ui/VirtualGrid";
|
||||
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
|
||||
|
||||
// The channel manager's `cards` layout (E4 S3), built by reusing the SAME `Column<T>` render closures
|
||||
@@ -84,7 +84,7 @@ export default function ChannelLayoutGrid<T>({
|
||||
items={rows}
|
||||
getKey={rowKey}
|
||||
minCol={spec.minCol}
|
||||
maxCol={null}
|
||||
colMaxWidth={null}
|
||||
rowEst={spec.rowEst}
|
||||
renderItem={renderCard}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import Avatar from "./Avatar";
|
||||
import Tooltip from "./Tooltip";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import { channelYouTubeUrl } from "../lib/format";
|
||||
|
||||
// Avatar + channel name + "open on YouTube" link, shared by the Channel manager and the
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { channelDetailPollMs } from "../lib/channelDetailPoll";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useNavigationActions } from "./NavigationProvider";
|
||||
import { PriorityStepper } from "./PriorityStepper";
|
||||
import { PriorityStepper } from "./ui/PriorityStepper";
|
||||
import { useChannelPriority } from "../lib/useChannelPriority";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -20,11 +20,11 @@ import {
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import Avatar from "./Avatar";
|
||||
import Tabs from "./Tabs";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import Feed from "./Feed";
|
||||
import ChannelAboutContent from "./ChannelAbout";
|
||||
import { PageToolbar, PageToolbarSlot } from "./PageShell";
|
||||
import { PageToolbar, PageToolbarSlot } from "./ui/PageShell";
|
||||
import { useFeedFilters } from "./FeedFiltersProvider";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { notify } from "../lib/notifications";
|
||||
@@ -416,14 +416,14 @@ export default function ChannelPage({
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
{ch?.subscribed && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onShowInFeed}
|
||||
title={t("channel.showInFeed")}
|
||||
aria-label={t("channel.showInFeed")}
|
||||
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-accent border border-border hover:border-accent transition"
|
||||
>
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
/>
|
||||
)}
|
||||
{ch?.subscribed && !me.is_demo && (
|
||||
<div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, type FeedFilters } from "../lib/api";
|
||||
import Combobox, { type ComboboxOption } from "./Combobox";
|
||||
import Combobox, { type ComboboxOption } from "./ui/Combobox";
|
||||
|
||||
// Pick channels to narrow the feed to — several OR together, so every other filter then applies
|
||||
// across all of them. Until this existed the filter was settable only via a shared "?channel=" link:
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type MyStatus } from "../lib/api";
|
||||
import { formatEta } from "../lib/format";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
// The channel-manager catalog stats, collapsed from a crowded inline row into a compact "Stats" chip
|
||||
// that reveals the numbers on hover (reuses the AnchoredPopover hover mode). One row per stat, each
|
||||
|
||||
@@ -2,10 +2,10 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Check, Plus, Sparkles, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Tag } from "../lib/api";
|
||||
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
|
||||
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
|
||||
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
|
||||
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
|
||||
@@ -129,7 +129,7 @@ function SuggestedChipsLane({
|
||||
}, [hidden, overflowOpen]);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-1 min-w-0 flex-1">
|
||||
<div className={`${chipLaneClass} flex items-center gap-1 min-w-0 flex-1`}>
|
||||
{/* Hidden measurer — real ghost chips at natural width so the fit hook reads their right edges. */}
|
||||
<div
|
||||
aria-hidden
|
||||
@@ -374,7 +374,7 @@ export function ChannelTagsControl({
|
||||
// collapses the rest into "+N". (max-w-full lets a narrower card shrink it.) Unbounded, the row
|
||||
// takes its container's width, so a wide header shows the tags inline instead of collapsing them.
|
||||
<div
|
||||
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
|
||||
className={`${chipLaneClass} flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
|
||||
>
|
||||
{/* The add trigger LEADS the row — a stable left anchor that never moves as chips are added
|
||||
(they grow to its right), so its editor popover can't drift. Compact icon in the width-
|
||||
|
||||
@@ -10,26 +10,27 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
||||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import DataTable from "./DataTable";
|
||||
import Tabs, { type TabDef } from "./Tabs";
|
||||
import { Button } from "./ui/Button";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import DataTable from "./ui/DataTable";
|
||||
import Tabs, { type TabDef } from "./ui/Tabs";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||
import { useTagSuggestions } from "./useTagSuggestions";
|
||||
import { useChannelPriority } from "../lib/useChannelPriority";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import ColumnSortControl from "./ColumnSortControl";
|
||||
import FilterChipGroup from "./FilterChipGroup";
|
||||
import ColumnSortControl from "./ui/ColumnSortControl";
|
||||
import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
import TagFilterChips, { type FilterChip } from "./TagFilterChips";
|
||||
import ActionMenu, { type ActionMenuItem } from "./ActionMenu";
|
||||
import ActionMenu, { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import ChannelStatsPopover from "./ChannelStatsPopover";
|
||||
import Pager from "./Pager";
|
||||
import Pager from "./ui/Pager";
|
||||
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
|
||||
import { sortRows } from "../lib/columnSort";
|
||||
import { filterRows } from "./tableHeader";
|
||||
import { filterRows } from "./ui/tableHeader";
|
||||
import { useCardPager } from "../lib/useCardPager";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import ChannelDiscovery from "./ChannelDiscovery";
|
||||
import TagManager from "./TagManager";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
@@ -489,13 +490,15 @@ export default function Channels() {
|
||||
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
|
||||
"Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
|
||||
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="shrink-0 uppercase tracking-wide"
|
||||
onClick={() => setTagManagerOpen(true)}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-xs uppercase tracking-wide text-muted transition hover:border-accent hover:text-accent"
|
||||
icon={<Pencil className="h-3 w-3" />}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
{t("channels.tags.label")}
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TagFilterChips chips={tagFilterChips} className="flex-1" />
|
||||
{tagFilter.length > 0 && (
|
||||
@@ -518,12 +521,9 @@ export default function Channels() {
|
||||
</span>
|
||||
</Tooltip>
|
||||
{highConfidence.length > 0 && (
|
||||
<button
|
||||
onClick={acceptAllHighConfidence}
|
||||
className="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:border-accent hover:text-accent transition"
|
||||
>
|
||||
<Button variant="outline" size="xs" onClick={acceptAllHighConfidence}>
|
||||
{t("channels.tags.acceptAllHigh", { count: highConfidence.length })}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -22,8 +22,8 @@ import {
|
||||
hasChannelsParams,
|
||||
type ChannelsUrlState,
|
||||
} from "../lib/channelsUrlState";
|
||||
import { usePersistedTab } from "./Tabs";
|
||||
import type { FilterMap } from "./tableHeader";
|
||||
import { usePersistedTab } from "./ui/Tabs";
|
||||
import type { FilterMap } from "./ui/tableHeader";
|
||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
import type { ChannelsView } from "./Channels";
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
} from "../lib/chatDock";
|
||||
import { onMessage, onUnread } from "../lib/messagesSocket";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import Avatar from "./Avatar";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import ChatThread from "./ChatThread";
|
||||
import Overlay from "./Overlay";
|
||||
import Overlay from "./ui/Overlay";
|
||||
|
||||
// The Messenger-style floating chat dock: open conversations live here, bottom-right, across
|
||||
// page navigation (state persisted per user). Always mounted (for human users) so it also owns
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Send } from "lucide-react";
|
||||
import { api, HttpError, type Message, type MessageUser } from "../lib/api";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { relativeTime } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { partnerPub, POLL_MS, refreshPartnerPub, useKeyState } from "../lib/messaging";
|
||||
@@ -213,15 +214,16 @@ export default function ChatThread({
|
||||
placeholder={t("messages.writePlaceholder")}
|
||||
className="flex-1 resize-none max-h-28 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={submit}
|
||||
disabled={!draft.trim() || send.isPending}
|
||||
className="shrink-0 p-2.5 rounded-xl btn-primary transition disabled:opacity-40"
|
||||
aria-label={t("messages.send")}
|
||||
title={t("messages.send")}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Send className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -5,12 +5,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plug, RotateCcw, Send } from "lucide-react";
|
||||
import { api, type ConfigItem, type PlexTestResult } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import InlineNotice from "./InlineNotice";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import InlineNotice from "./ui/InlineNotice";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import Tabs, { usePersistedTab } from "./Tabs";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import Tabs, { usePersistedTab } from "./ui/Tabs";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { Switch } from "./ui/form";
|
||||
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
|
||||
import { LS } from "../lib/storage";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CalendarDays, Check, ChevronDown } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
import { type FeedFilters } from "../lib/api";
|
||||
import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import Banner from "./Banner";
|
||||
import Banner from "./ui/Banner";
|
||||
|
||||
// Permanent, non-dismissible bar shown to the shared demo account so it's always clear the
|
||||
// state is communal. (Replaces the old login-time toast, which re-popped on every reload.)
|
||||
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
} from "lucide-react";
|
||||
import Tabs from "./Tabs";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
|
||||
// used (the video editor in particular is heavy — filmstrip + scrubber).
|
||||
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
||||
|
||||
@@ -2,7 +2,7 @@ import { lazy, Suspense, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import { api } from "../lib/api";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { dismissError, getError, subscribeError } from "../lib/errorDialog";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// Renders the current app error (if any) as a modal the user must acknowledge.
|
||||
|
||||
@@ -38,12 +38,13 @@ import { FEED_VIEWS, type FeedView } from "../lib/feedView";
|
||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||
import ActiveFilterChips from "./ActiveFilterChips";
|
||||
import SelectedViewChip from "./SelectedViewChip";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useWizard } from "./WizardProvider";
|
||||
import SortControl from "./SortControl";
|
||||
import FilterSelect from "./FilterSelect";
|
||||
import ItemCount from "./ItemCount";
|
||||
import SortControl from "./ui/SortControl";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
import ItemCount from "./ui/ItemCount";
|
||||
import ViewSwitcher from "./ViewSwitcher";
|
||||
import VirtualFeed from "./VirtualFeed";
|
||||
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
||||
@@ -530,20 +531,18 @@ export default function Feed({
|
||||
{isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")}
|
||||
</p>
|
||||
{isDemo ? (
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => setFilters({ ...filters, scope: "all" })}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold btn-primary transition"
|
||||
>
|
||||
{t("feed.browseLibrary")}
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={onOpenWizard}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold btn-primary transition"
|
||||
>
|
||||
<Button variant="primary" size="lg" onClick={onOpenWizard}>
|
||||
{t("feed.setUp")}
|
||||
</button>
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, scope: "all" })}
|
||||
className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Library, User } from "lucide-react";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
|
||||
// The feed's Mine/Library scope, relocated from the filter sidebar to sit just right of the header
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { defaultLayout, type PanelId, type PanelLayout } from "../lib/panelLayout";
|
||||
import SidePanel from "./SidePanel";
|
||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||
import SidePanel from "./ui/SidePanel";
|
||||
import PanelGroups, { type PanelItem } from "./ui/PanelGroups";
|
||||
|
||||
// The shared filter-rail shell, extracted from the three rails that all render the same SidePanel
|
||||
// chrome around a PanelGroups body: the feed Sidebar, the PlexSidebar, and the PlaylistsRail. It owns
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Overlay from "./Overlay";
|
||||
import Overlay from "./ui/Overlay";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { usePrefs } from "./PrefsProvider";
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Lock, ShieldCheck } from "lucide-react";
|
||||
import { api, HttpError } from "../lib/api";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import { Button } from "./ui/Button";
|
||||
import { inputCls } from "./ui/form";
|
||||
|
||||
// Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device.
|
||||
@@ -107,21 +108,17 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
)}
|
||||
{err && <div className="text-sm text-danger">{err}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={cancelReset}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-xl text-sm border border-border hover:border-accent transition disabled:opacity-40"
|
||||
>
|
||||
<Button variant="outline" onClick={cancelReset} disabled={busy}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={doReset}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-danger/90 text-danger-fg hover:bg-danger transition disabled:opacity-40"
|
||||
icon={<AlertTriangle className="w-4 h-4" />}
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
{t("messages.resetButton")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -157,14 +154,10 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
/>
|
||||
)}
|
||||
{err && <div className="text-sm text-danger">{err}</div>}
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || !pass}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition disabled:opacity-40"
|
||||
>
|
||||
<Button variant="primary" size="md" onClick={submit} disabled={busy || !pass}>
|
||||
<Lock className="w-4 h-4" />
|
||||
{configured ? t("messages.unlock") : t("messages.setUp")}
|
||||
</button>
|
||||
</Button>
|
||||
{configured && (
|
||||
<button onClick={toReset} className="block text-xs text-muted hover:text-fg">
|
||||
{t("messages.forgotPass")}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Check, Globe } from "lucide-react";
|
||||
import { LANGUAGES, type LangCode } from "../i18n";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
// Compact language picker (globe + current code). Presentational: the parent decides what
|
||||
// changing the language does (set it; persist server-side when signed in).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { jobTitle } from "./downloadRow";
|
||||
|
||||
|
||||
@@ -5,13 +5,14 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
|
||||
import { api, type Conversation, type MessageUser } from "../lib/api";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { relativeTime } from "../lib/format";
|
||||
import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging";
|
||||
import { useHistorySubview } from "../lib/history";
|
||||
import { openChat } from "../lib/chatDock";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import Avatar from "./Avatar";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import KeyGate from "./KeyGate";
|
||||
import { inputCls } from "./ui/form";
|
||||
import ChatThread from "./ChatThread";
|
||||
@@ -123,13 +124,10 @@ function ConversationList({
|
||||
{t("messages.conversations")}
|
||||
</div>
|
||||
{ready && (
|
||||
<button
|
||||
onClick={onNew}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg btn-primary transition"
|
||||
>
|
||||
<Button variant="primary" size="xs" onClick={onNew}>
|
||||
<SquarePen className="w-4 h-4" />
|
||||
{t("messages.new")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(keyState === "setup" || keyState === "unlock") && <KeyGate meId={meId} />}
|
||||
@@ -213,12 +211,15 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
// An icon-only button needs a name: both of these announced themselves as a bare "button",
|
||||
// and they are the only way out of the sub-view (E10 review round 1).
|
||||
label={t("channel.back")}
|
||||
onClick={onBack}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<ArrowLeft className="w-4 h-4" />}
|
||||
/>
|
||||
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.newTitle")}</div>
|
||||
</div>
|
||||
<input
|
||||
@@ -280,12 +281,15 @@ function PageThread({
|
||||
return (
|
||||
<div className="flex flex-col h-[65vh] min-h-80">
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-border">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
// An icon-only button needs a name: both of these announced themselves as a bare "button",
|
||||
// and they are the only way out of the sub-view (E10 review round 1).
|
||||
label={t("channel.back")}
|
||||
onClick={onBack}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<ArrowLeft className="w-4 h-4" />}
|
||||
/>
|
||||
<Avatar
|
||||
src={seedAvatar}
|
||||
fallback={name}
|
||||
|
||||
@@ -12,10 +12,11 @@ import type { Page } from "../lib/urlState";
|
||||
import { moduleDef, moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { type LangCode } from "../i18n";
|
||||
import AvatarImg from "./Avatar";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { Button } from "./ui/Button";
|
||||
import AvatarImg from "./ui/Avatar";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
|
||||
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
|
||||
@@ -221,22 +222,22 @@ export default function NavSidebar({
|
||||
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
|
||||
<div className="flex flex-col items-center justify-center gap-2 pb-2 mb-1 border-b border-border">
|
||||
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onOpenAbout}
|
||||
title={t("header.account.about")}
|
||||
aria-label={t("header.account.about")}
|
||||
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<Info className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
icon={<Info className="w-5 h-5" />}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={logout}
|
||||
title={t("header.account.signOut")}
|
||||
aria-label={t("header.account.signOut")}
|
||||
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
</button>
|
||||
icon={<LogOut className="w-5 h-5" />}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip hint={t("header.account.settings")} side="right" always openDelay={350}>
|
||||
<button
|
||||
|
||||
@@ -37,9 +37,10 @@ import {
|
||||
type VideoWatchedMeta,
|
||||
} from "../lib/notifications";
|
||||
import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
|
||||
import { LEVEL_STYLE } from "./Toaster";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LEVEL_STYLE } from "./ui/Toaster";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { useNavigationActions } from "./NavigationProvider";
|
||||
import { useChannelsActions } from "./ChannelsProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
@@ -132,22 +133,24 @@ export default function NotificationsPanel() {
|
||||
</div>
|
||||
{hasAny && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={markAll}
|
||||
disabled={!hasUnread || readAllMut.isPending}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40"
|
||||
icon={<CheckCheck className="w-4 h-4" />}
|
||||
>
|
||||
<CheckCheck className="w-4 h-4" />
|
||||
{t("inbox.markAllRead")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={clearEverything}
|
||||
disabled={clearMut.isPending}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40"
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t("inbox.clearAll")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -305,43 +308,45 @@ function NotificationRow({
|
||||
</ul>
|
||||
)}
|
||||
{isScheduler && (
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm mt-1.5"
|
||||
onClick={onOpenScheduler}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1.5"
|
||||
icon={<Activity className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Activity className="w-3.5 h-3.5" />
|
||||
{t("inbox.openScheduler")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{isDownloadDone && (
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm mt-1.5"
|
||||
onClick={onOpenDownloads}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1.5"
|
||||
icon={<Download className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{t("inbox.openDownloads")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{!n.read && (
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onRead}
|
||||
title={t("inbox.markRead")}
|
||||
aria-label={t("inbox.markRead")}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Check className="w-4 h-4" />}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDismiss}
|
||||
title={t("inbox.dismiss")}
|
||||
aria-label={t("inbox.dismiss")}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<X className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -396,47 +401,52 @@ function ClientActivityRow({
|
||||
</div>
|
||||
{hidden ? (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onFind(hidden)}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||
icon={<Search className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
{t("notifications.findInFeed")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onRevert(hidden)}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||
icon={<Eye className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
{t("notifications.unhide")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : watched ? (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onFind(watched)}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||
icon={<Search className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
{t("notifications.findInFeed")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onRevert(watched)}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||
icon={<RotateCcw className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t("notifications.unwatch")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : subscribed ? (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onFocusChannel(subscribed.channelName)}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||
icon={<Tv className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Tv className="w-3.5 h-3.5" />
|
||||
{t("notifications.openInManager")}
|
||||
</button>
|
||||
</Button>
|
||||
<a
|
||||
href={channelYouTubeUrl(subscribed.channelId)}
|
||||
target="_blank"
|
||||
@@ -448,36 +458,39 @@ function ClientActivityRow({
|
||||
</a>
|
||||
</div>
|
||||
) : access ? (
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm mt-1"
|
||||
onClick={() => onReviewAccess()}
|
||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1"
|
||||
icon={<UserPlus className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
{t("common.accessRequestsReview")}
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
n.action &&
|
||||
!n.dismissed && (
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
className="mt-1 text-sm"
|
||||
onClick={() => {
|
||||
n.action!.onClick();
|
||||
dismissClient(n.id);
|
||||
}}
|
||||
className="mt-1 text-accent text-sm font-semibold hover:underline"
|
||||
>
|
||||
{n.action.label}
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onRemove}
|
||||
title={t("inbox.dismiss")}
|
||||
aria-label={t("inbox.dismiss")}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<X className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { qk } from "../lib/queryKeys";
|
||||
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
|
||||
import { api, type Me } from "../lib/api";
|
||||
import { beginGrant, endOnboarding } from "../lib/onboarding";
|
||||
import Overlay from "./Overlay";
|
||||
import { Button } from "./ui/Button";
|
||||
import Overlay from "./ui/Overlay";
|
||||
|
||||
// A first-login wizard that walks the user through granting YouTube access one scope at a
|
||||
// time, each with a plain explanation and an up-front heads-up about Google's "unverified
|
||||
@@ -159,12 +160,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
>
|
||||
<Youtube className="w-4 h-4" /> {t("onboarding.read.connect")}
|
||||
</button>
|
||||
<button
|
||||
onClick={close}
|
||||
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
|
||||
>
|
||||
<Button variant="ghost" size="md" onClick={close}>
|
||||
{t("onboarding.read.skip")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -225,15 +223,16 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" /> {t("onboarding.write.enableUnsubscribe")}
|
||||
</button>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
onClick={() => {
|
||||
endOnboarding(false);
|
||||
onClose();
|
||||
}}
|
||||
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
|
||||
>
|
||||
{t("onboarding.write.keepReadOnly")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -17,13 +17,13 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import { useSavedViews, asFilters } from "./SavedViewsProvider";
|
||||
import { useActiveFilters } from "../lib/useActiveFilters";
|
||||
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
||||
import { useUndoable } from "../lib/useUndoable";
|
||||
import UndoToolbar from "./UndoToolbar";
|
||||
import UndoToolbar from "./ui/UndoToolbar";
|
||||
|
||||
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
|
||||
// reorder) plus a "save the current filters" input; the right column shows the selected view's name
|
||||
|
||||
@@ -28,9 +28,9 @@ import {
|
||||
VolumeX,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Avatar from "./Avatar";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
import { api, type Video, type VideoStatus } from "../lib/api";
|
||||
@@ -44,7 +44,8 @@ import {
|
||||
} from "../lib/format";
|
||||
import { renderDescription } from "../lib/descriptionLinks";
|
||||
import { fullscreenElement } from "../lib/fullscreen";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
||||
import { closeThenNavigate, useBackToClose } from "../lib/history";
|
||||
import {
|
||||
useNativeControlsYield,
|
||||
useYtBigView,
|
||||
@@ -329,72 +330,76 @@ export default function PlayerModal({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcuts (Esc close / F fullscreen / Space play-pause) + background scroll lock.
|
||||
useKeyScope("ytPlayer");
|
||||
|
||||
// Keyboard shortcuts + background scroll lock. The chords live in the shared keymap (`ytPlayer`
|
||||
// scope) so the help sheet lists them; only the behaviour behind each id is here.
|
||||
// These fire only while focus is on our page, not inside the cross-origin player iframe — so
|
||||
// we focus the modal card on open and again on player-ready (autoplay can grab focus).
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
|
||||
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
|
||||
// maximise or close the player underneath it. The consumed-event flag is the reliable signal
|
||||
// (UX-8): the layer's `document` listener runs before ours and its close can pop it off the
|
||||
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
|
||||
if (escapeHandled(e)) return;
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
|
||||
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
||||
if (fullscreenElement()) return;
|
||||
// Same for our own maximise: Esc steps back out of it before it closes the player.
|
||||
if (maximizedRef.current) {
|
||||
e.preventDefault();
|
||||
setMaximized(false);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
// Volume by keyboard (YouTube/Plex-style ±5). The only volume control that survives fullscreen,
|
||||
// where the wheel goes to YouTube's own iframe. But the modal card scrolls (a long description)
|
||||
// and Up/Down is how you scroll it from the keyboard — so defer whenever the focused element can
|
||||
// still scroll that way, exactly like Space defers to a focused button. NOT while the video fills
|
||||
// the screen though — in fullscreen OR in our own maximise — because the card is behind/off-screen
|
||||
// there: deferring would scroll something invisible and leave the viewer with no volume control at
|
||||
// all. Both flags, not just fullscreen: since F stopped taking the browser's lock, maximise is the
|
||||
// common case and checking only `isFullscreen` made the keys dead exactly where they matter most.
|
||||
const volume = (e: KeyboardEvent, up: boolean) => {
|
||||
const covered = isFullscreenRef.current || maximizedRef.current;
|
||||
if (!covered && canScrollBy(document.activeElement as HTMLElement | null, up ? -1 : 1))
|
||||
return;
|
||||
}
|
||||
const el = document.activeElement as HTMLElement | null;
|
||||
const tag = (el?.tagName || "").toLowerCase();
|
||||
const typing =
|
||||
tag === "input" || tag === "textarea" || tag === "select" || !!el?.isContentEditable;
|
||||
if (e.key === "f" || e.key === "F") {
|
||||
if (typing) return;
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
} else if (e.key === " " || e.code === "Space") {
|
||||
// Let Space activate a focused button/link; otherwise toggle playback (and stop the
|
||||
// page from scrolling).
|
||||
if (typing || tag === "button" || tag === "a") return;
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
// Volume by keyboard (YouTube/Plex-style ±5). The only volume control that survives
|
||||
// fullscreen, where the wheel goes to YouTube's own iframe. But the modal card scrolls
|
||||
// (a long description) and Up/Down is how you scroll it from the keyboard — so defer
|
||||
// whenever the focused element can still scroll that way, exactly like Space defers to a
|
||||
// focused button. NOT while the video fills the screen though — in fullscreen OR in our own
|
||||
// maximise — because the card is behind/off-screen there: deferring would scroll something
|
||||
// invisible and leave the viewer with no volume control at all. Both flags, not just
|
||||
// fullscreen: since F stopped taking the browser's lock, maximise is the common case and
|
||||
// checking only `isFullscreen` made the keys dead exactly where they matter most.
|
||||
const up = e.key === "ArrowUp";
|
||||
const covered = isFullscreenRef.current || maximizedRef.current;
|
||||
if (typing || (!covered && canScrollBy(el, up ? -1 : 1))) return;
|
||||
e.preventDefault();
|
||||
nudgeVolume(up ? 5 : -5);
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
// Plain arrows seek within the video (YouTube-style ±5s); Shift+arrow steps to the
|
||||
// previous/next video in the queue (the feed's order or a playlist).
|
||||
if (typing) return;
|
||||
e.preventDefault();
|
||||
const forward = e.key === "ArrowRight";
|
||||
if (e.shiftKey) {
|
||||
if (forward) goNext();
|
||||
else goPrev();
|
||||
} else {
|
||||
nudgeSeek(forward ? 5 : -5);
|
||||
}
|
||||
}
|
||||
e.preventDefault();
|
||||
nudgeVolume(up ? 5 : -5);
|
||||
};
|
||||
const seek = (e: KeyboardEvent, secs: number) => {
|
||||
e.preventDefault();
|
||||
nudgeSeek(secs);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
dispatch(e, "ytPlayer", {
|
||||
close: (ev) => {
|
||||
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
|
||||
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
|
||||
// maximise or close the player underneath it. The consumed-event flag is the reliable signal
|
||||
// (UX-8): the layer's `document` listener runs before ours and its close can pop it off the
|
||||
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
|
||||
if (escapeHandled(ev)) return;
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
|
||||
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
||||
if (fullscreenElement()) return;
|
||||
// Same for our own maximise: Esc steps back out of it before it closes the player.
|
||||
if (maximizedRef.current) {
|
||||
ev.preventDefault();
|
||||
setMaximized(false);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
fullscreen: (ev) => {
|
||||
ev.preventDefault();
|
||||
toggleFullscreen();
|
||||
},
|
||||
playPause: (ev) => {
|
||||
// Let Space activate a focused button/link; otherwise toggle playback (and stop the
|
||||
// page from scrolling).
|
||||
if (isActivationTarget(ev)) return;
|
||||
ev.preventDefault();
|
||||
togglePlay();
|
||||
},
|
||||
volumeUp: (ev) => volume(ev, true),
|
||||
volumeDown: (ev) => volume(ev, false),
|
||||
seekBack: (ev) => seek(ev, -5),
|
||||
seekFwd: (ev) => seek(ev, 5),
|
||||
prevVideo: (ev) => {
|
||||
ev.preventDefault();
|
||||
goPrev();
|
||||
},
|
||||
nextVideo: (ev) => {
|
||||
ev.preventDefault();
|
||||
goNext();
|
||||
},
|
||||
});
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
focusModal();
|
||||
@@ -751,16 +756,11 @@ export default function PlayerModal({
|
||||
onClick={() => {
|
||||
const cid = active.channel_id;
|
||||
const cname = active.channel_title ?? undefined;
|
||||
// Closing the player pops its own history entry (useBackToClose → history.back()).
|
||||
// Open the channel only AFTER that pop's popstate — pushing the channel entry
|
||||
// before it would let the back remove it again (the bug: clicking the name only
|
||||
// closed the player). One-shot listener fires after the popstate handler.
|
||||
const open = () => {
|
||||
window.removeEventListener("popstate", open);
|
||||
openChannel(cid, cname);
|
||||
};
|
||||
window.addEventListener("popstate", open);
|
||||
onClose();
|
||||
// Close first, open the channel only once this player's own Back-to-close
|
||||
// entry has been reclaimed — otherwise that reclaim undoes the push and
|
||||
// clicking the name merely closes the player. The rule lives in history.ts
|
||||
// now; the command palette needs exactly the same one.
|
||||
closeThenNavigate(onClose, () => openChannel(cid, cname));
|
||||
}}
|
||||
className="font-medium hover:text-accent truncate text-left"
|
||||
>
|
||||
|
||||
@@ -21,8 +21,13 @@ import {
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ArrowDownToLine,
|
||||
ArrowUp,
|
||||
ArrowUpToLine,
|
||||
Check,
|
||||
ExternalLink,
|
||||
FolderInput,
|
||||
GripVertical,
|
||||
ListPlus,
|
||||
Pencil,
|
||||
@@ -31,9 +36,10 @@ import {
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
Youtube,
|
||||
} from "lucide-react";
|
||||
import { api, HttpError, type Video } from "../lib/api";
|
||||
import { api, HttpError, type Playlist, type Video } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
@@ -42,10 +48,15 @@ import { cycleSort, type SortState } from "../lib/columnSort";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { applyView, sameOrder } from "../lib/playlistView";
|
||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
import UndoToolbar from "./UndoToolbar";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { useChoice, useConfirm } from "./ConfirmProvider";
|
||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||
import { Button } from "./ui/Button";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import UndoToolbar from "./ui/UndoToolbar";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { usePlaylistActions } from "./usePlaylistActions";
|
||||
import { usePlaylists } from "./PlaylistsProvider";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import {
|
||||
ColumnHead,
|
||||
@@ -54,7 +65,7 @@ import {
|
||||
isActive,
|
||||
type Column,
|
||||
type FilterMap,
|
||||
} from "./tableHeader";
|
||||
} from "./ui/tableHeader";
|
||||
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
|
||||
|
||||
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
|
||||
@@ -87,11 +98,16 @@ function Row({
|
||||
columns,
|
||||
dragDisabled,
|
||||
dragHint,
|
||||
menuProps,
|
||||
}: {
|
||||
video: Video;
|
||||
columns: Column<Video>[];
|
||||
dragDisabled: boolean;
|
||||
dragHint: string;
|
||||
/** useContextMenu's trigger props. On the WHOLE row, not a handle: a right-click anywhere in the
|
||||
* row opens the menu, the way a native list does. The row's own buttons keep their left-click
|
||||
* behaviour — a right-click just isn't a button press. */
|
||||
menuProps: Record<string, unknown>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
@@ -111,6 +127,7 @@ function Row({
|
||||
// transition-COLORS, not `transition` — the bare utility animates transform too, which is
|
||||
// the same lag by another route (150ms behind the pointer, on every row).
|
||||
className="border-b border-border/50 hover:bg-card/40 transition-colors"
|
||||
{...menuProps}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
@@ -152,11 +169,10 @@ export default function Playlists() {
|
||||
// The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the
|
||||
// list + its rail controls). This detail pane reacts to the selection.
|
||||
const { selectedId } = usePlaylists();
|
||||
const { setSelectedId } = usePlaylistsActions();
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const choose = useChoice();
|
||||
const plActions = usePlaylistActions();
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
// The stored order is undoable: drag and "save this order" both go through `order.set`, so each
|
||||
@@ -201,6 +217,9 @@ export default function Playlists() {
|
||||
// a change of view would otherwise slide a different video under the index — or unmount the player
|
||||
// mid-playback. PlayerModal already tracks its own queue by id; this is the parent half of that.
|
||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||
// ONE context menu for the whole table, not one popover per <tr> (see ui/ContextMenu): the row it
|
||||
// belongs to is `rowMenu.target`, and the panel anchors to the pointer.
|
||||
const rowMenu = useContextMenu<Video>();
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: qk.playlists(),
|
||||
@@ -482,58 +501,65 @@ export default function Playlists() {
|
||||
const removeRef = useRef(removeItem);
|
||||
removeRef.current = removeItem;
|
||||
|
||||
async function moveItemTo(video: Video, target: Playlist) {
|
||||
if (selectedId == null) return;
|
||||
// Optimistic, exactly like removeItem: from THIS list's point of view a move is a removal, and
|
||||
// rebasing keeps the undo stack valid for what's left instead of throwing it away.
|
||||
const next = items.filter((v) => v.id !== video.id);
|
||||
order.rebase((list) => list.filter((v) => v.id !== video.id));
|
||||
lastSetRef.current = `${selectedId}:${idsKey(next)}`;
|
||||
try {
|
||||
// ONE request. The two-call version (add there, remove here) leaves the video in BOTH lists
|
||||
// whenever the second call fails — see the endpoint's docstring.
|
||||
await api.moveToPlaylist(selectedId, video.id, target.id);
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("playlists.movedToast", { title: video.title, name: plName(target) }),
|
||||
});
|
||||
} catch (e) {
|
||||
// NOT a silent revert. `req` deliberately leaves 401/403/404 to the caller as control flow, and
|
||||
// every move failure a user can actually reach is a 404 (the 400 `same_playlist` case is already
|
||||
// impossible — the submenu never offers the current list). So the row used to snap back with no
|
||||
// explanation and the shipped moveErrors strings were unreachable (E10 review round 1).
|
||||
// ONLY the statuses `req` deliberately leaves to the caller. It already opens the blocking
|
||||
// error dialog for 400/409/422 and 5xx and raises the connectivity notice for a bad gateway,
|
||||
// so toasting unconditionally reported a transport fault twice (E10 review round 2).
|
||||
if (e instanceof HttpError && (e.status === 404 || e.status === 403))
|
||||
notify({ level: "error", message: e.detail || t("playlists.moveFailed") });
|
||||
}
|
||||
// BOTH sides changed, and so did this video's membership. `qk.playlist(selectedId)` is an exact
|
||||
// key, so it cannot cover the target — invalidate the whole `["playlist"]` prefix, exactly as
|
||||
// AddToPlaylist does for the same reason.
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||
qc.invalidateQueries({ queryKey: qk.playlistMembership(video.id) });
|
||||
}
|
||||
|
||||
// Keyboard/menu reordering, for the same stored order the drag handle writes — so it goes through
|
||||
// `order.set` (undoable + persisted), not a direct API call. Gated by the same rule as the drag
|
||||
// handle: a view (sort/group/filter) is not the stored order, so moving "up" inside one would
|
||||
// write something the user never saw.
|
||||
function moveInOrder(video: Video, to: "top" | "up" | "down" | "bottom") {
|
||||
const from = items.findIndex((v) => v.id === video.id);
|
||||
if (from < 0) return;
|
||||
const target =
|
||||
to === "top" ? 0 : to === "bottom" ? items.length - 1 : to === "up" ? from - 1 : from + 1;
|
||||
if (target === from || target < 0 || target >= items.length) return;
|
||||
order.set(arrayMove(items, from, target)); // onApply persists
|
||||
}
|
||||
|
||||
async function saveRename() {
|
||||
if (selectedId == null) return;
|
||||
const name = renameValue.trim();
|
||||
setRenaming(false);
|
||||
if (name && name !== detail?.name) {
|
||||
try {
|
||||
await api.renamePlaylist(selectedId, name);
|
||||
} catch (e) {
|
||||
notifyYouTubeActionError(e, t("playlists.renameFailed"));
|
||||
}
|
||||
refreshAll();
|
||||
}
|
||||
if (name && name !== detail?.name) await plActions.rename(selectedId, name);
|
||||
}
|
||||
|
||||
async function deletePlaylist() {
|
||||
if (selectedId == null || !detail) return;
|
||||
// ONE dialog with every outcome. It used to be two chained confirms, where the second one's
|
||||
// Cancel/Escape/backdrop meant "delete here only" — dismissing the dialog deleted the playlist.
|
||||
const offerYoutube = linked && canWrite;
|
||||
const picked = await choose({
|
||||
title: t("playlists.delete"),
|
||||
message: offerYoutube
|
||||
? t("playlists.confirmDeleteLinked", { name: plName(detail) })
|
||||
: t("playlists.confirmDelete", { name: plName(detail) }),
|
||||
choices: offerYoutube
|
||||
? [
|
||||
{ id: "here", label: t("playlists.deleteHereOnly"), danger: true },
|
||||
{ id: "both", label: t("playlists.deleteBoth"), danger: true },
|
||||
]
|
||||
: [{ id: "here", label: t("playlists.delete"), danger: true }],
|
||||
});
|
||||
if (picked == null) return;
|
||||
const onYoutube = picked === "both";
|
||||
const deletedId = selectedId;
|
||||
// Pick the next selection from what's left *now* (don't go via null, or the
|
||||
// auto-select effect would re-pick the just-deleted id from the stale list).
|
||||
const remaining = playlists.filter((p) => p.id !== deletedId);
|
||||
setSelectedId(remaining[0]?.id ?? null);
|
||||
try {
|
||||
await api.deletePlaylist(deletedId, onYoutube);
|
||||
} catch (err) {
|
||||
// Prefer the server's specific reason (e.g. a local vs YouTube-side failure) over a blanket
|
||||
// "couldn't delete on YouTube" that misleads when the failure wasn't the YouTube side.
|
||||
notify({
|
||||
level: "warning",
|
||||
message:
|
||||
(err instanceof HttpError && err.detail) ||
|
||||
(onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")),
|
||||
});
|
||||
}
|
||||
qc.removeQueries({ queryKey: qk.playlist(deletedId) });
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
if (!detail) return;
|
||||
// The dialog, the "delete on YouTube too?" branch and the re-selection live in
|
||||
// usePlaylistActions — the rail's context menu deletes through the SAME code.
|
||||
await plActions.remove(detail);
|
||||
}
|
||||
|
||||
function playerState(id: string, status: string) {
|
||||
@@ -573,6 +599,85 @@ export default function Playlists() {
|
||||
: t("playlists.dragBlockedSort");
|
||||
const playingIndex = playingId ? shown.findIndex((v) => v.id === playingId) : -1;
|
||||
|
||||
// The row menu, built from the row the user right-clicked. Same item shape (and therefore same
|
||||
// rows, same paint, same keyboard model) as any ⋯ ActionMenu in the app.
|
||||
const moveTargets = playlists.filter((p) => p.id !== selectedId);
|
||||
const storedOrderEditable = canEdit && !viewActive;
|
||||
const ctxRow = rowMenu.target;
|
||||
const ctxItems: ActionMenuItem[] = !ctxRow
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "play",
|
||||
label: t("card.play"),
|
||||
icon: <Play className="w-4 h-4" />,
|
||||
onSelect: () => setPlayingId(ctxRow.id),
|
||||
},
|
||||
...(canEdit
|
||||
? ([
|
||||
moveTargets.length
|
||||
? {
|
||||
id: "move",
|
||||
label: t("playlists.moveTo"),
|
||||
icon: <FolderInput className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
items: moveTargets.map((pl) => ({
|
||||
id: `move-${pl.id}`,
|
||||
label: plName(pl),
|
||||
icon:
|
||||
pl.source === "youtube" || pl.yt_playlist_id ? (
|
||||
<Youtube className="w-4 h-4" />
|
||||
) : (
|
||||
<ListPlus className="w-4 h-4" />
|
||||
),
|
||||
onSelect: () => void moveItemTo(ctxRow, pl),
|
||||
})),
|
||||
}
|
||||
: // Kept visible and disabled rather than dropped: an action that vanishes teaches
|
||||
// nobody why. KNOWN GAP: the `title` that carries the why currently reaches nobody
|
||||
// either — see ActionMenu's `title` prop, where the two failed attempts at fixing
|
||||
// that are recorded. The row is honest about being off; it is not yet honest about
|
||||
// the reason.
|
||||
{
|
||||
id: "move",
|
||||
label: t("playlists.moveTo"),
|
||||
icon: <FolderInput className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
disabled: true,
|
||||
title: t("playlists.moveNoTargets"),
|
||||
onSelect: () => {},
|
||||
},
|
||||
...(
|
||||
[
|
||||
["top", t("playlists.orderTop"), <ArrowUpToLine className="w-4 h-4" />],
|
||||
["up", t("playlists.orderUp"), <ArrowUp className="w-4 h-4" />],
|
||||
["down", t("playlists.orderDown"), <ArrowDown className="w-4 h-4" />],
|
||||
["bottom", t("playlists.orderBottom"), <ArrowDownToLine className="w-4 h-4" />],
|
||||
] as const
|
||||
).map(([to, label, icon], i) => ({
|
||||
id: `order-${to}`,
|
||||
label,
|
||||
icon,
|
||||
separatorBefore: i === 0,
|
||||
// Same gate as the drag handle. NOTE the drag handle explains itself and this row
|
||||
// does not: the handle is `aria-disabled` (focusable, tooltip-capable) while a menu
|
||||
// row cannot be — see ActionMenu's `title` prop for why.
|
||||
disabled: !storedOrderEditable,
|
||||
title: storedOrderEditable ? undefined : dragHint,
|
||||
onSelect: () => moveInOrder(ctxRow, to),
|
||||
})),
|
||||
{
|
||||
id: "remove",
|
||||
label: t("playlists.removeItem"),
|
||||
icon: <X className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
danger: true,
|
||||
onSelect: () => void removeItem(ctxRow),
|
||||
},
|
||||
] satisfies ActionMenuItem[])
|
||||
: []),
|
||||
];
|
||||
|
||||
if (selectedId == null) {
|
||||
return <div className="text-muted text-sm p-4">{t("playlists.pickOne")}</div>;
|
||||
}
|
||||
@@ -585,12 +690,9 @@ export default function Playlists() {
|
||||
<AlertTriangle className="w-4 h-4 text-danger" />
|
||||
{t("playlists.loadFailed")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => detailQuery.refetch()}
|
||||
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted hover:text-fg hover:border-accent transition"
|
||||
>
|
||||
<Button variant="outline" size="xs" onClick={() => detailQuery.refetch()}>
|
||||
{t("common.retry")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -703,25 +805,26 @@ export default function Playlists() {
|
||||
</button>
|
||||
)}
|
||||
{linked && detail.dirty && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={revertToYoutube}
|
||||
disabled={reverting}
|
||||
title={t("playlists.revert")}
|
||||
aria-label={t("playlists.revert")}
|
||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{editable && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
onClick={deletePlaylist}
|
||||
title={t("playlists.delete")}
|
||||
aria-label={t("playlists.delete")}
|
||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
icon={<Trash2 className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -755,12 +858,14 @@ export default function Playlists() {
|
||||
</button>
|
||||
)}
|
||||
{canSaveOrder && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline-accent"
|
||||
size="xs"
|
||||
onClick={saveOrder}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
|
||||
icon={<Check className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
|
||||
</button>
|
||||
{t("playlists.saveOrder")}
|
||||
</Button>
|
||||
)}
|
||||
{/* ONE sentence that names the state. Three loose texts used to pile up here
|
||||
(reset link + save hint + "reordering is off"), and none of them answered the
|
||||
@@ -829,6 +934,7 @@ export default function Playlists() {
|
||||
columns={columns}
|
||||
dragDisabled={!canEdit || viewActive}
|
||||
dragHint={dragHint}
|
||||
menuProps={rowMenu.triggerProps(v)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -843,6 +949,9 @@ export default function Playlists() {
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* One surface for the whole table, outside the rows — it anchors to the pointer, not the row. */}
|
||||
<ContextMenu menu={rowMenu} ariaLabel={t("playlists.rowMenu")} items={ctxItems} />
|
||||
|
||||
{playingIndex >= 0 && (
|
||||
<Suspense fallback={null}>
|
||||
<PlayerModal
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
|
||||
import { ListVideo, Pencil, Pin, Plus, RefreshCw, Trash2, Youtube } from "lucide-react";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
@@ -10,20 +10,29 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
||||
import { type PanelLayout } from "../lib/panelLayout";
|
||||
import FilterRail from "./FilterRail";
|
||||
import { type PanelItem } from "./PanelGroups";
|
||||
import SortControl from "./SortControl";
|
||||
import { Button } from "./ui/Button";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { type PanelItem } from "./ui/PanelGroups";
|
||||
import SortControl from "./ui/SortControl";
|
||||
import { type SortOption } from "../lib/columnSort";
|
||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
import { usePlaylistActions } from "./usePlaylistActions";
|
||||
|
||||
type PlSort = {
|
||||
key: "custom" | "name" | "count" | "duration";
|
||||
key: "custom" | "name" | "count" | "duration" | "recent";
|
||||
dir: "asc" | "desc";
|
||||
dirtyFirst: boolean;
|
||||
};
|
||||
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
|
||||
|
||||
// One geometry for the row in both of its shapes (the selectable button, and the rename field it
|
||||
// becomes) — the row must not resize the moment you rename it.
|
||||
const ROW_BASE = "w-full flex items-center gap-2.5 p-2 rounded-lg border transition";
|
||||
|
||||
// The Playlists module's left rail — lifted to App level (like the filter rails) so it floats in the
|
||||
// shared SidePanel with the same collapse-to-tab behavior. It owns the list query + its own rail
|
||||
// controls (sort / new / sync); the selected playlist is App state so the module's detail pane
|
||||
@@ -52,12 +61,23 @@ export default function PlaylistsRail({
|
||||
{ key: "name", label: t("playlists.railSortName"), defaultDir: "asc" },
|
||||
{ key: "count", label: t("playlists.railSortCount"), defaultDir: "desc" },
|
||||
{ key: "duration", label: t("playlists.railSortDuration"), defaultDir: "desc" },
|
||||
// E10 S2 — "the one I just touched, first". Reads `updated_at`, which the API bumps for item
|
||||
// adds/removes/moves/reorders too, not just renames (see the backend's _touch). NOT the
|
||||
// default: a rail that reshuffles itself while you look at it costs more than it saves.
|
||||
{ key: "recent", label: t("playlists.railSortRecent"), defaultDir: "desc" },
|
||||
];
|
||||
useEffect(() => {
|
||||
writeAccount(LS.plSort, plSort);
|
||||
}, [plSort]);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const plActions = usePlaylistActions();
|
||||
// Right-click / long-press anywhere on a rail row. ONE menu for the whole list (see ui/ContextMenu).
|
||||
const railMenu = useContextMenu<Playlist>();
|
||||
// Renaming happens IN the row that was right-clicked, not in the detail header — the point of the
|
||||
// context menu is that the action lands where the gesture did.
|
||||
const [renamingId, setRenamingId] = useState<number | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
||||
const scrolledRef = useRef(false);
|
||||
|
||||
@@ -95,6 +115,8 @@ export default function PlaylistsRail({
|
||||
return sign * (a.item_count - b.item_count);
|
||||
case "duration":
|
||||
return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0));
|
||||
case "recent":
|
||||
return sign * (Date.parse(a.updated_at) - Date.parse(b.updated_at));
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
@@ -141,20 +163,93 @@ export default function PlaylistsRail({
|
||||
},
|
||||
});
|
||||
|
||||
// KNOWN GAP: Enter and Escape unmount the focused <input> and re-render the row as a <button> that
|
||||
// nothing focuses, so a keyboard user is dropped on <body> and loses their place in the rail (E10
|
||||
// review round 1). A refocus-the-row fix was written in round 1 and REMOVED in round 3 because a
|
||||
// real-browser check showed it never worked: Escape's handler does run (typing then Escape leaves
|
||||
// the name unchanged, i.e. it cancels rather than blur-commits) and the row button is focusable
|
||||
// when focused from outside — yet activeElement is still BODY afterwards. Three explanations were
|
||||
// tried and none survived contact, so the machinery went rather than a fourth guess. Whoever picks
|
||||
// this up: instrument the effect in a real browser first, do not reason about the commit order.
|
||||
const renameInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// UNRESOLVED — do not "simplify" this back to `autoFocus` without reading the whole story.
|
||||
//
|
||||
// Opening this field from the context menu is a focus race: `MenuItemList` runs `onSelect()` and
|
||||
// `onClose()` in one batch, so the field mounts in the same commit that unmounts the popover, and
|
||||
// floating-ui's FloatingFocusManager hands focus back from a `queueMicrotask` queued in its
|
||||
// layout-effect cleanup. If that microtask wins, the field is blurred straight back out and its
|
||||
// own onBlur commits and closes it — "the rename field flashes and vanishes" (E10 review round 1).
|
||||
//
|
||||
// Three answers have been tried and none is VERIFIED. Round 1 turned floating-ui's returnFocus off
|
||||
// platform-wide and broke two other surfaces (a non-focusable <tr> trigger; a scroll-dismiss that
|
||||
// scrolled the list back). Round 2 replaced it with this effect, on the reasoning that a passive
|
||||
// effect is scheduled after the microtask. Round 3 disputed that (React flushes passive effects
|
||||
// synchronously at the end of a SyncLane commit, which would put the microtask LAST) — and a jsdom
|
||||
// harness built to settle it reproduced no failure for ANY of autoFocus / this effect / a
|
||||
// microtask, i.e. jsdom does not exercise the race, because focus never enters the menu there.
|
||||
//
|
||||
// So: this is the shape that is deliberate rather than accidental, and the question is open. It
|
||||
// needs ONE real-browser check (open the rail's context menu, pick Rename, see whether the field
|
||||
// survives) — not a fourth guess. Logged in the E10 plan.
|
||||
useEffect(() => {
|
||||
if (renamingId !== null) renameInputRef.current?.focus();
|
||||
}, [renamingId]);
|
||||
|
||||
async function commitRename(pl: Playlist) {
|
||||
const name = renameValue.trim();
|
||||
setRenamingId(null);
|
||||
if (name && name !== pl.name) await plActions.rename(pl.id, name);
|
||||
}
|
||||
|
||||
const ctxPl = railMenu.target;
|
||||
// The built-in Watch later list is renameable/deletable nowhere else in the UI either; the rows
|
||||
// stay visible and disabled so the menu doesn't change shape from list to list.
|
||||
const ctxBuiltin = ctxPl?.kind === "watch_later";
|
||||
// Interpolated, not spelled out: the HU copy said "Watch later", a name the Hungarian UI never
|
||||
// shows for this list ("Megnézem később") — so the message named a list the reader had never seen
|
||||
// (E10 review round 1). Passing the localized name makes the two impossible to drift.
|
||||
const lockedTitle = ctxBuiltin
|
||||
? t("playlists.builtinLocked", { name: t("playlists.watchLater") })
|
||||
: undefined;
|
||||
const railItems: ActionMenuItem[] = !ctxPl
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "rename",
|
||||
label: t("playlists.rename"),
|
||||
icon: <Pencil className="w-4 h-4" />,
|
||||
disabled: ctxBuiltin,
|
||||
title: lockedTitle,
|
||||
onSelect: () => {
|
||||
setRenameValue(ctxPl.name);
|
||||
setRenamingId(ctxPl.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
label: t("playlists.delete"),
|
||||
icon: <Trash2 className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
danger: true,
|
||||
disabled: ctxBuiltin,
|
||||
title: lockedTitle,
|
||||
// The same dialog (and the same "what gets selected next" rule) as the detail header's
|
||||
// trash button — one implementation, two entry points.
|
||||
onSelect: () => void plActions.remove(ctxPl),
|
||||
},
|
||||
];
|
||||
|
||||
const items: PanelItem[] = [
|
||||
{
|
||||
id: "manage",
|
||||
title: t("playlists.manageGroup"),
|
||||
body: (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={syncYoutube}
|
||||
disabled={syncing}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted hover:border-accent hover:text-accent disabled:opacity-40 transition"
|
||||
>
|
||||
<Button variant="outline" size="xs" fullWidth onClick={syncYoutube} disabled={syncing}>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? "animate-spin" : ""}`} />
|
||||
{t("playlists.syncYoutube")}
|
||||
</button>
|
||||
</Button>
|
||||
{playlists.length > 1 && (
|
||||
<SortControl
|
||||
label={t("playlists.sortLabel")}
|
||||
@@ -219,40 +314,70 @@ export default function PlaylistsRail({
|
||||
<div className="text-xs text-muted px-1 py-1">{t("playlists.noMatches")}</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{visiblePlaylists.map((pl) => (
|
||||
<button
|
||||
key={pl.id}
|
||||
ref={pl.id === selectedId ? selectedRef : null}
|
||||
onClick={() => setSelectedId(pl.id)}
|
||||
className={`w-full flex items-center gap-2.5 p-2 rounded-lg border transition text-left ${
|
||||
pl.id === selectedId
|
||||
? "bg-accent/15 border-accent"
|
||||
: "border-transparent hover:bg-card/60"
|
||||
}`}
|
||||
>
|
||||
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
|
||||
{pl.cover_thumbnail && (
|
||||
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1 text-[0.8125rem] text-fg">
|
||||
<span className="truncate">{plName(pl)}</span>
|
||||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
||||
<Youtube
|
||||
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
|
||||
/>
|
||||
{visiblePlaylists.map((pl) =>
|
||||
renamingId === pl.id ? (
|
||||
// The row becomes its own rename field. A <button> can't contain an <input>, so the
|
||||
// whole row swaps rather than the label alone — but it KEEPS the cover, because
|
||||
// that is what holds the row at its two-line height. Without it the rail jumps by
|
||||
// ~12px the moment you start renaming, and every row below it moves under the
|
||||
// pointer.
|
||||
<div key={pl.id} className={`${ROW_BASE} bg-accent/10 border-accent`}>
|
||||
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
|
||||
{pl.cover_thumbnail && (
|
||||
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-2xs text-muted">
|
||||
{t("playlists.itemCount", { count: pl.item_count })}
|
||||
{pl.total_duration_seconds > 0 &&
|
||||
` · ${formatRuntime(pl.total_duration_seconds)}`}
|
||||
</div>
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={() => void commitRename(pl)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void commitRename(pl);
|
||||
// Unmounting the input fires no blur in React, so Escape really does cancel.
|
||||
if (e.key === "Escape") setRenamingId(null);
|
||||
}}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
) : (
|
||||
<button
|
||||
key={pl.id}
|
||||
ref={pl.id === selectedId ? selectedRef : null}
|
||||
onClick={() => setSelectedId(pl.id)}
|
||||
{...railMenu.triggerProps(pl)}
|
||||
className={`${ROW_BASE} text-left ${
|
||||
pl.id === selectedId
|
||||
? "bg-accent/15 border-accent"
|
||||
: "border-transparent hover:bg-card/60"
|
||||
}`}
|
||||
>
|
||||
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
|
||||
{pl.cover_thumbnail && (
|
||||
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1 text-[0.8125rem] text-fg">
|
||||
<span className="truncate">{plName(pl)}</span>
|
||||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
||||
<Youtube
|
||||
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-2xs text-muted">
|
||||
{t("playlists.itemCount", { count: pl.item_count })}
|
||||
{pl.total_duration_seconds > 0 &&
|
||||
` · ${formatRuntime(pl.total_duration_seconds)}`}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<ContextMenu menu={railMenu} ariaLabel={t("playlists.railMenu")} items={railItems} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -25,20 +25,21 @@ import { useDebounced } from "../lib/useDebounced";
|
||||
import { useHistorySubview } from "../lib/history";
|
||||
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import SortControl from "./SortControl";
|
||||
import FilterSelect from "./FilterSelect";
|
||||
import SortControl from "./ui/SortControl";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
import PlexDiscover from "./PlexDiscover";
|
||||
import { PlexCollectionsBrowse, PlexPlaylistsBrowse } from "./plexBrowseLists";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import ActiveFilterChips from "./ActiveFilterChips";
|
||||
import SelectedViewChip from "./SelectedViewChip";
|
||||
import ItemCount from "./ItemCount";
|
||||
import { StateMessage } from "./QueryState";
|
||||
import ItemCount from "./ui/ItemCount";
|
||||
import { StateMessage } from "./ui/QueryState";
|
||||
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
import { usePublishPlexView } from "./PlexViewBridge";
|
||||
import { EpisodeCard, PlexPosterCard } from "./plexCards";
|
||||
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
|
||||
import { dispatch, useKeyScope } from "../lib/keymap";
|
||||
|
||||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
||||
@@ -64,6 +65,20 @@ const LASTWATCHED_SORT: NonNullable<SortState> = Object.freeze({
|
||||
// I looking at" control now, like the feed's Watch-state pill, not a rail widget).
|
||||
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
||||
|
||||
// The drill-down views Backspace can step out of. "player" is deliberately absent: the player owns
|
||||
// its own Backspace (stop & back to the grid), and this handler must not fire underneath it.
|
||||
const BACKABLE_VIEWS: readonly string[] = [
|
||||
"grid",
|
||||
"playlists",
|
||||
"collections",
|
||||
"show",
|
||||
"season",
|
||||
"info",
|
||||
"person",
|
||||
"playlist",
|
||||
"collection",
|
||||
];
|
||||
|
||||
export default function PlexBrowse() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
@@ -163,35 +178,24 @@ export default function PlexBrowse() {
|
||||
}, [sub.view.kind]);
|
||||
|
||||
// Backspace steps back one drill-down level (grid ← show ← season, and out of info/playlist),
|
||||
// matching the browser/mouse Back. The player has its OWN Backspace handling, so we skip it here;
|
||||
// and never hijack Backspace while typing in a field.
|
||||
// matching the browser/mouse Back; the chord and the typing guard come from the shared keymap.
|
||||
// BACKABLE_VIEWS is the ONE list behind both the guard and the scope registration, so the cheat
|
||||
// sheet can never promise a "Back one level" the handler declines to perform.
|
||||
const canStepBack = BACKABLE_VIEWS.includes(sub.view.kind);
|
||||
useKeyScope("plexBrowse", canStepBack);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Backspace") return;
|
||||
const el = document.activeElement as HTMLElement | null;
|
||||
const tag = (el?.tagName || "").toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable)
|
||||
return;
|
||||
if (
|
||||
[
|
||||
"grid",
|
||||
"playlists",
|
||||
"collections",
|
||||
"show",
|
||||
"season",
|
||||
"info",
|
||||
"person",
|
||||
"playlist",
|
||||
"collection",
|
||||
].includes(sub.view.kind)
|
||||
) {
|
||||
e.preventDefault();
|
||||
sub.back();
|
||||
}
|
||||
dispatch(e, "plexBrowse", {
|
||||
back: (ev) => {
|
||||
if (!canStepBack) return;
|
||||
ev.preventDefault();
|
||||
sub.back();
|
||||
},
|
||||
});
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [sub]);
|
||||
}, [sub, canStepBack]);
|
||||
|
||||
// D3: entering the in-progress watch-state view auto-applies the "last watched" sort (most recently
|
||||
// watched first); leaving restores the sort that was active before entering. Only the ENTER edge
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Check, Plus } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { notifyCollectionEditError } from "../lib/actionErrors";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import Modal from "./Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
|
||||
// Admin-only "manage this movie's collections" dialog (Collections P2). Lists the library's EDITABLE
|
||||
@@ -138,14 +139,16 @@ export default function PlexCollectionEditor({
|
||||
placeholder={t("plex.collEditor.newPlaceholder")}
|
||||
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
variant="glass"
|
||||
size="md"
|
||||
className="shrink-0"
|
||||
onClick={createNew}
|
||||
disabled={!newName.trim() || !!busy}
|
||||
className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("plex.collEditor.create")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi";
|
||||
import { PlexPosterCard } from "./plexCards";
|
||||
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
|
||||
// The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the
|
||||
// ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external,
|
||||
|
||||
@@ -8,9 +8,9 @@ import PosterTile from "./PosterTile";
|
||||
import { plexLang } from "../i18n";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { usePlex } from "./PlexProvider";
|
||||
import { Carousel } from "./Carousel";
|
||||
import { Carousel } from "./ui/Carousel";
|
||||
import { PlexPosterCard } from "./plexCards";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
|
||||
// The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE
|
||||
// /api/plex/discover call — Continue watching, Recently added, Browse by genre (tiles), Because you
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "../lib/plexDetailUi";
|
||||
import { usePref } from "../lib/usePref";
|
||||
import i18n from "../i18n";
|
||||
import { Carousel } from "./Carousel";
|
||||
import { Carousel } from "./ui/Carousel";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||
import { PLAYABLE_TINT, playableClass } from "./plexCards";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { LoadingState } from "./QueryState";
|
||||
import { LoadingState } from "./ui/QueryState";
|
||||
|
||||
// A debounced library search-and-pick list, shared by the "Add title" panel (PlexCollectionView) and
|
||||
// the "New collection" first-title panel (plexBrowseLists) — both search the library the same way and
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
Square,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, HttpError } from "../lib/api";
|
||||
import {
|
||||
@@ -53,6 +52,16 @@ import {
|
||||
useWallClock,
|
||||
} from "../lib/plexPlayerHooks";
|
||||
import { fullscreenElement } from "../lib/fullscreen";
|
||||
import {
|
||||
dispatch,
|
||||
isActivationTarget,
|
||||
isTypingTarget,
|
||||
toggleKeyboardHelp,
|
||||
useHelpConditions,
|
||||
useKeyboardHelpOpen,
|
||||
useKeyScope,
|
||||
usePaletteOpen,
|
||||
} from "../lib/keymap";
|
||||
import { LS, useAccountPersistedObject } from "../lib/storage";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
@@ -176,10 +185,33 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
},
|
||||
[tracksFade.ref],
|
||||
);
|
||||
// Keyboard-shortcut cheat sheet (toggled with "h") + a live wall clock for the top bar.
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
// The keyboard cheat sheet is app-wide now (ui/KeyboardHelp, opened by the global "h"), not this
|
||||
// player's own overlay. Mirror its open state into a ref because usePlayerVolume still needs to
|
||||
// know an overlay is up before it steals a wheel scroll for volume.
|
||||
const helpOpen = useKeyboardHelpOpen();
|
||||
const helpOpenRef = useRef(false);
|
||||
helpOpenRef.current = helpOpen;
|
||||
// Read for the Backspace guard below: these two overlays open OVER the player and own the key.
|
||||
const paletteOpenRef = useRef(false);
|
||||
paletteOpenRef.current = usePaletteOpen();
|
||||
|
||||
// On-screen confirmation for a KEYBOARD track switch. A/S have cycled audio and subtitles since
|
||||
// E5, but silently — you changed the track and the only way to see what you landed on was to open
|
||||
// the tracks menu, which is exactly the modal round-trip the shortcut exists to avoid. The menu
|
||||
// itself needs no OSD: it shows the selection where you clicked.
|
||||
const [osd, setOsd] = useState<string | null>(null);
|
||||
const osdTimer = useRef<number | null>(null);
|
||||
const flashOsd = useCallback((text: string) => {
|
||||
setOsd(text);
|
||||
if (osdTimer.current) window.clearTimeout(osdTimer.current);
|
||||
osdTimer.current = window.setTimeout(() => setOsd(null), 2200);
|
||||
}, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (osdTimer.current) window.clearTimeout(osdTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
// "I" toggles a rich info overlay (poster/cast/ratings) over the running video.
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const infoOpenRef = useRef(false);
|
||||
@@ -577,15 +609,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
useEffect(() => setUpNextOpen(false), [id]);
|
||||
|
||||
// "Back" cascade shared by the on-screen Back arrow AND the keyboard/remote Backspace·Escape: peel
|
||||
// off whatever is layered on top (a tuning/tracks menu, then the shortcuts sheet, then the info
|
||||
// overlay) before leaving the player. The arrow used to call onClose() unconditionally, so Back
|
||||
// with the info/shortcuts panel open jumped straight to the feed instead of just closing the panel.
|
||||
// off whatever is layered on top (a tuning/tracks menu, then the info overlay) before leaving the
|
||||
// player. The arrow used to call onClose() unconditionally, so Back with the info panel open
|
||||
// jumped straight to the feed instead of just closing the panel. The shortcuts sheet is no longer
|
||||
// in this cascade: it is a Modal above us now, and owns its own Escape layer and Back-to-close.
|
||||
const handleBack = useCallback(() => {
|
||||
if (menuOpenRef.current) {
|
||||
setMenuOpen(false);
|
||||
setTracksOpen(false);
|
||||
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||
else if (infoOpenRef.current) setInfoOpen(false);
|
||||
} else if (infoOpenRef.current) setInfoOpen(false);
|
||||
else if (statsOpenRef.current) setStatsOpen(false);
|
||||
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||
else onClose();
|
||||
@@ -603,6 +635,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
handleBack();
|
||||
}, [handleBack, skipProgressRef, cancelAutoSkipRef]);
|
||||
|
||||
// Tell the shared cheat sheet which keys are live here, and whether ⇧← / ⇧→ apply (they step
|
||||
// episodes, so they are listed only inside a series).
|
||||
useKeyScope("plexPlayer");
|
||||
useHelpConditions({ episode: detail?.kind === "episode" });
|
||||
|
||||
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (its own
|
||||
// settings/tracks menus included) takes Escape first and this player defers (R8 S3).
|
||||
const layerIdRef = useRef<number | null>(null);
|
||||
@@ -617,92 +654,101 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
|
||||
// --- keyboard --------------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const seekBy = (e: KeyboardEvent, dir: -1 | 1, fine: boolean) => {
|
||||
e.preventDefault();
|
||||
seekTo(absRef.current + dir * (fine ? fineSeekStepRef.current : seekStepRef.current));
|
||||
};
|
||||
const step = (e: KeyboardEvent, id: string | null | undefined) => {
|
||||
e.preventDefault();
|
||||
go(id);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||
wake(); // keyboard use also reveals the controls
|
||||
switch (e.key) {
|
||||
case " ":
|
||||
// Keyboard use also reveals the controls — but typing in a player field is not "using the
|
||||
// remote", and `dispatch` still lets Escape through from inside a field (the only key that
|
||||
// opts in), which is why this is a guard around `wake` rather than an early return.
|
||||
if (!isTypingTarget(e)) wake();
|
||||
dispatch(e, "plexPlayer", {
|
||||
playPause: (ev) => {
|
||||
// Let Space activate a focused button/link natively (e.g. "Skip intro"); only toggle
|
||||
// playback when the focus isn't on a control — matching PlayerModal's guard.
|
||||
if (tag === "BUTTON" || tag === "A") return;
|
||||
e.preventDefault();
|
||||
// playback when the focus isn't on a control — the same shared rule PlayerModal uses.
|
||||
if (isActivationTarget(ev)) return;
|
||||
ev.preventDefault();
|
||||
togglePlay();
|
||||
break;
|
||||
case "k":
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
break;
|
||||
case "f":
|
||||
toggleFs();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) go(prevId);
|
||||
else seekTo(absRef.current - (e.ctrlKey ? fineSeekStepRef.current : seekStepRef.current));
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) go(nextId);
|
||||
else seekTo(absRef.current + (e.ctrlKey ? fineSeekStepRef.current : seekStepRef.current));
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
},
|
||||
fullscreen: () => toggleFs(),
|
||||
seekBack: (ev) => seekBy(ev, -1, false),
|
||||
seekFwd: (ev) => seekBy(ev, 1, false),
|
||||
fineBack: (ev) => seekBy(ev, -1, true),
|
||||
fineFwd: (ev) => seekBy(ev, 1, true),
|
||||
prevEpisode: (ev) => step(ev, prevId),
|
||||
nextEpisode: (ev) => step(ev, nextId),
|
||||
volumeUp: (ev) => {
|
||||
ev.preventDefault();
|
||||
nudgeVolume(0.05);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
},
|
||||
volumeDown: (ev) => {
|
||||
ev.preventDefault();
|
||||
nudgeVolume(-0.05);
|
||||
break;
|
||||
case "m":
|
||||
toggleMute();
|
||||
break;
|
||||
case "a":
|
||||
case "A":
|
||||
cycleAudio();
|
||||
break;
|
||||
case "s":
|
||||
case "S":
|
||||
cycleSubtitle();
|
||||
break;
|
||||
case "h":
|
||||
case "H":
|
||||
setHelpOpen((v) => !v);
|
||||
break;
|
||||
case "i":
|
||||
case "I":
|
||||
setInfoOpen((v) => !v);
|
||||
break;
|
||||
case "d":
|
||||
case "D":
|
||||
setStatsOpen((v) => !v);
|
||||
break;
|
||||
case "Backspace":
|
||||
},
|
||||
mute: () => toggleMute(),
|
||||
audio: () => {
|
||||
const ord = cycleAudio();
|
||||
if (ord === undefined) return; // one track (or none): nothing to say
|
||||
const label = detail?.audio_streams.find((a) => a.ord === (ord ?? 0))?.label;
|
||||
flashOsd(`${t("plex.player.audio")} · ${label ?? "—"}`);
|
||||
},
|
||||
subtitle: () => {
|
||||
const ord = cycleSubtitle();
|
||||
if (ord === undefined) return;
|
||||
const label =
|
||||
ord == null
|
||||
? t("plex.player.subOff")
|
||||
: (detail?.subtitle_streams.find((x) => x.ord === ord)?.label ?? "—");
|
||||
flashOsd(`${t("plex.player.subtitles")} · ${label}`);
|
||||
},
|
||||
info: () => setInfoOpen((v) => !v),
|
||||
stats: () => setStatsOpen((v) => !v),
|
||||
back: (ev) => {
|
||||
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
|
||||
e.preventDefault();
|
||||
// Who owns ⌫ when something is open over the player. Modal stopPropagation()s Escape ONLY
|
||||
// and its focused close button is not a typing target, so the key reaches us regardless.
|
||||
// Two wrong answers so far: deferring to nothing at all (round 1's bug — the cheat sheet
|
||||
// took ⌫ and the player stopped underneath it), then deferring to `anyLayerAbove` outright
|
||||
// (round 1's fix, round 2's bug — the player's OWN gear/tracks popovers push layers too, so
|
||||
// ⌫ stopped closing them), then naming just the sheet and palette (round 2's fix, round 3's
|
||||
// bug — the app-wide ErrorDialog is neither, so ⌫ tore the player down under it).
|
||||
// The rule that holds: the player's own panels answer first, because `handleBack`'s cascade
|
||||
// is what closes them; anything else layered above us takes the key.
|
||||
const ownPanelOpen =
|
||||
menuOpenRef.current ||
|
||||
infoOpenRef.current ||
|
||||
statsOpenRef.current ||
|
||||
upNextOpenRef.current;
|
||||
if (!ownPanelOpen && layerIdRef.current != null && anyLayerAbove(layerIdRef.current))
|
||||
return;
|
||||
ev.preventDefault();
|
||||
if (skipProgressRef.current != null)
|
||||
cancelAutoSkipRef.current(); // cancel auto-skip, don't go back
|
||||
else handleBack();
|
||||
break;
|
||||
case "Escape":
|
||||
},
|
||||
escape: (ev) => {
|
||||
// Defer to any layer opened above the player (Modal or anchored popover) — let it take the
|
||||
// Escape, not us. A player MENU registers as a layer too, so it closes first and the next
|
||||
// Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug). Check the
|
||||
// consumed-event flag first (UX-8): a layer's `document` listener runs before this `window`
|
||||
// one and its close can pop it off the stack synchronously, so `anyLayerAbove` alone misses it.
|
||||
if (escapeHandled(e)) break;
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) break;
|
||||
if (escapeHandled(ev)) return;
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
|
||||
if (skipProgressRef.current != null) cancelAutoSkipRef.current();
|
||||
else if (menuOpenRef.current) {
|
||||
setMenuOpen(false);
|
||||
setTracksOpen(false);
|
||||
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||
else if (infoOpenRef.current) setInfoOpen(false);
|
||||
} else if (infoOpenRef.current) setInfoOpen(false);
|
||||
else if (statsOpenRef.current) setStatsOpen(false);
|
||||
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||
else if (!fullscreenElement()) onClose();
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
@@ -715,6 +761,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
toggleMute,
|
||||
cycleAudio,
|
||||
cycleSubtitle,
|
||||
flashOsd,
|
||||
t,
|
||||
detail,
|
||||
prevId,
|
||||
nextId,
|
||||
@@ -846,15 +894,30 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard track-switch OSD. Sits ABOVE the control bar's own space so it reads even while the
|
||||
bar is showing, and inside the player element so it survives fullscreen. `aria-live` because
|
||||
the whole point is confirming something you cannot otherwise see.
|
||||
The REGION is always mounted and only the bubble is conditional: a polite region has to
|
||||
pre-exist in the accessibility tree for a later insertion to be announced, so one created
|
||||
together with its own first message is unreliably read — and since `osd` clears after 2.2s,
|
||||
conditioning the wrapper re-created it for every switch, not just the first (E10 review
|
||||
round 1). Toaster.tsx carries the same rule and the same shape. */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-24 z-menu flex justify-center px-4"
|
||||
aria-live="polite"
|
||||
>
|
||||
{osd && (
|
||||
<div className="max-w-full truncate rounded-full border border-white/15 bg-black/75 px-4 py-2 text-sm text-white shadow-2xl">
|
||||
{osd}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* While a panel/menu is open or an auto-skip countdown is running, take over the browser/mouse
|
||||
Back button (history popstate) so it closes/cancels that instead of dropping to the grid. */}
|
||||
{(menuOpen ||
|
||||
tracksOpen ||
|
||||
helpOpen ||
|
||||
infoOpen ||
|
||||
statsOpen ||
|
||||
upNextOpen ||
|
||||
skipProgress != null) && <BackClose onBack={backIntent} />}
|
||||
{(menuOpen || tracksOpen || infoOpen || statsOpen || upNextOpen || skipProgress != null) && (
|
||||
<BackClose onBack={backIntent} />
|
||||
)}
|
||||
|
||||
{/* Top bar */}
|
||||
<div
|
||||
@@ -1197,7 +1260,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
|
||||
<Info className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.help")} onClick={() => setHelpOpen((v) => !v)}>
|
||||
<Ctrl label={t("plex.player.help")} onClick={toggleKeyboardHelp}>
|
||||
<Keyboard className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.download")} onClick={download}>
|
||||
@@ -1236,70 +1299,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
|
||||
{helpOpen && (
|
||||
<div
|
||||
className="absolute inset-0 z-menu grid place-items-center bg-black/60 p-4"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setHelpOpen(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-[min(92vw,26rem)] rounded-2xl border border-white/15 bg-neutral-900/95 p-5 text-white shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<Keyboard className="w-5 h-5" /> {t("plex.player.help")}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setHelpOpen(false)}
|
||||
className="p-1 rounded-lg hover:bg-white/10"
|
||||
aria-label={t("plex.player.close")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2.5 text-sm">
|
||||
{(
|
||||
[
|
||||
[["Space", "K"], "plex.player.keys.playPause"],
|
||||
[["←", "→"], "plex.player.keys.seek"],
|
||||
[["Ctrl ←", "Ctrl →"], "plex.player.keys.fineSeek"],
|
||||
...(detail?.kind === "episode"
|
||||
? [[["⇧ ←", "⇧ →"], "plex.player.keys.episode"] as [string[], string]]
|
||||
: []),
|
||||
[["↑", "↓"], "plex.player.keys.volume"],
|
||||
[["M"], "plex.player.keys.mute"],
|
||||
[["A"], "plex.player.keys.audio"],
|
||||
[["S"], "plex.player.keys.subtitle"],
|
||||
[["F"], "plex.player.keys.fullscreen"],
|
||||
[["I"], "plex.player.keys.info"],
|
||||
[["D"], "plex.player.keys.stats"],
|
||||
[["⌫", "Esc"], "plex.player.keys.back"],
|
||||
[["H"], "plex.player.keys.help"],
|
||||
] as [string[], string][]
|
||||
).map(([keys, labelKey]) => (
|
||||
<Fragment key={labelKey}>
|
||||
<dt className="flex flex-wrap gap-1">
|
||||
{keys.map((k) => (
|
||||
<kbd
|
||||
key={k}
|
||||
className="rounded-sm bg-white/15 px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
|
||||
>
|
||||
{k}
|
||||
</kbd>
|
||||
))}
|
||||
</dt>
|
||||
<dd className="text-white/80">{t(labelKey)}</dd>
|
||||
</Fragment>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rich info overlay (poster/cast/ratings) — "i" or the info button; video keeps playing. */}
|
||||
{infoOpen && detail && (
|
||||
<div
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Minus, Plus } from "lucide-react";
|
||||
import { api, type PlexPlaylist } from "../lib/api";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import Modal from "./Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
|
||||
// What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a
|
||||
// set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist.
|
||||
@@ -102,14 +103,16 @@ export default function PlexPlaylistAdd({
|
||||
placeholder={t("plex.playlistAdd.newPlaceholder")}
|
||||
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
variant="glass"
|
||||
size="md"
|
||||
className="shrink-0"
|
||||
onClick={createNew}
|
||||
disabled={!newName.trim() || busy}
|
||||
className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("plex.playlistAdd.create")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -35,7 +35,8 @@ import { api, type PlexCard } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
|
||||
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
|
||||
@@ -514,13 +515,10 @@ export default function PlexPlaylistView({
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-[90%] max-w-[1100px] p-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="glass-card glass-hover mb-4 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs"
|
||||
>
|
||||
<Button variant="glass" size="xs" className="mb-4" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("plex.backToLibrary")}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<div className="glass rounded-2xl p-4 sm:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
import { usePlexViewControls } from "./PlexViewBridge";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown, Film, Tv } from "lucide-react";
|
||||
import SearchBar from "./SearchBar";
|
||||
import ActionMenu from "./ActionMenu";
|
||||
import ActionMenu from "./ui/ActionMenu";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
@@ -9,10 +9,10 @@ import { type PanelLayout } from "../lib/panelLayout";
|
||||
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import FilterRail from "./FilterRail";
|
||||
import { type PanelItem } from "./PanelGroups";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import FilterChipGroup from "./FilterChipGroup";
|
||||
import Combobox, { type ComboboxOption } from "./Combobox";
|
||||
import { type PanelItem } from "./ui/PanelGroups";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
import Combobox, { type ComboboxOption } from "./ui/Combobox";
|
||||
import SavedViewsPopover from "./SavedViewsPopover";
|
||||
import PersonalViewsModal from "./PersonalViewsModal";
|
||||
import { useSavedViews } from "./SavedViewsProvider";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { usePlexViewControls } from "./PlexViewBridge";
|
||||
|
||||
// The Discover ⟷ Browse switch, hosted in the header next to the library-scope pill (All/Movies/Shows).
|
||||
|
||||
@@ -3,7 +3,7 @@ import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Lock, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
|
||||
import { inputCls } from "./ui/form";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bug, Sparkles } from "lucide-react";
|
||||
import { RELEASE_NOTES } from "../lib/releaseNotes";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
|
||||
function fmtDate(d: string): string {
|
||||
const t = new Date(d);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bookmark, Check, Settings2 } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
import { useSavedViews } from "./SavedViewsProvider";
|
||||
|
||||
// The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as
|
||||
|
||||
@@ -19,11 +19,12 @@ import {
|
||||
} from "lucide-react";
|
||||
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { relativeTime } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
|
||||
const POLL_MS = 4000;
|
||||
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
|
||||
@@ -283,13 +284,9 @@ function MaintenanceSettings({
|
||||
aria-label={t("scheduler.maintenance.batchLabel")}
|
||||
className="w-28 bg-surface border border-border rounded-sm px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={!dirty || saving}
|
||||
className="glass-card glass-hover px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
|
||||
>
|
||||
<Button variant="glass" size="sm" onClick={save} disabled={!dirty || saving}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-2xs text-muted mt-1.5">
|
||||
{t("scheduler.maintenance.batchNote", {
|
||||
|
||||
@@ -7,17 +7,17 @@ import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-r
|
||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||
import { api, HttpError, type Me } from "../lib/api";
|
||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||
import Avatar from "./Avatar";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import { notify, type NotifSettings } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import { inputCls, Section, SettingRow, Switch } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
import { type SaveState } from "./ui/DraftSaveBar";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { StateMessage } from "./QueryState";
|
||||
import { StateMessage } from "./ui/QueryState";
|
||||
import { useWizard } from "./WizardProvider";
|
||||
import { usePrefs } from "./PrefsProvider";
|
||||
import Tabs from "./Tabs";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import { useMe } from "../lib/useMe";
|
||||
|
||||
// The Settings page edits server-persisted preferences. Each change applies locally for instant
|
||||
@@ -324,12 +324,9 @@ function AccessRow({
|
||||
</span>
|
||||
) : (
|
||||
<Tooltip hint={t("settings.account.enableHint")}>
|
||||
<button
|
||||
onClick={onEnable}
|
||||
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
|
||||
>
|
||||
<Button variant="glass" size="sm" className="shrink-0" onClick={onEnable}>
|
||||
{t("settings.account.enable")}
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
@@ -498,13 +495,15 @@ function SignInMethods({ me }: { me: Me }) {
|
||||
{t("settings.account.sessions.hint")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="glass"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={logoutOthers}
|
||||
disabled={sessBusy}
|
||||
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition disabled:opacity-40"
|
||||
>
|
||||
{t("settings.account.sessions.action")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
@@ -676,7 +675,7 @@ export function DownloadAuth() {
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{h.configured && (
|
||||
<Button
|
||||
variant="danger-outline"
|
||||
variant="danger-soft"
|
||||
size="sm"
|
||||
onClick={() => onRemove(h.host_key)}
|
||||
disabled={remove.isPending}
|
||||
@@ -833,13 +832,10 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||
<p className="text-xs text-muted leading-relaxed mb-2">
|
||||
{t("settings.account.deleteHint")}
|
||||
</p>
|
||||
<button
|
||||
onClick={onDeleteAccount}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm border border-danger/40 text-danger hover:bg-danger/10 transition"
|
||||
>
|
||||
<Button variant="danger-soft" size="md" onClick={onDeleteAccount}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t("settings.account.deleteAccount")}
|
||||
</button>
|
||||
</Button>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Check, ChevronLeft, ChevronRight, Loader2, Send } from "lucide-react";
|
||||
import { api, HttpError } from "../lib/api";
|
||||
import { setLanguage, type LangCode } from "../i18n";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { Button } from "./ui/Button";
|
||||
import { inputCls } from "./ui/form";
|
||||
|
||||
// First-run install wizard. Shown by App.tsx while the instance is unconfigured. The one-time
|
||||
@@ -239,15 +240,17 @@ export default function SetupWizard() {
|
||||
placeholder={t("setup.smtp.testTo")}
|
||||
className={inputCls}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
variant="glass"
|
||||
size="md"
|
||||
className="shrink-0"
|
||||
type="button"
|
||||
onClick={sendTest}
|
||||
disabled={testState === "sending" || !testTo.trim()}
|
||||
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<Send className={`w-4 h-4 ${testState === "sending" ? "animate-pulse" : ""}`} />
|
||||
{t("setup.smtp.test")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{testState === "ok" && (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
@@ -138,31 +138,39 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
||||
value={fullUrl}
|
||||
className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-hidden"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={copy}
|
||||
aria-label={t("downloads.share.copy")}
|
||||
title={t("downloads.share.copy")}
|
||||
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg transition"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
{/* All three on the same rung. Only Copy was migrated in S1, which left one 2rem button
|
||||
between two 1.75rem ones with a different radius and hover fill — and, on a coarse
|
||||
pointer, `.touch-target` grew ONLY the migrated one to 2.75rem while the destructive
|
||||
Revoke stayed small (E10 review round 1). */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
setNewPass("");
|
||||
setEditPass((v) => !v);
|
||||
}}
|
||||
title={
|
||||
label={
|
||||
link.has_password
|
||||
? t("downloads.share.changePassword")
|
||||
: t("downloads.share.setPassword")
|
||||
}
|
||||
className={clsx(
|
||||
"p-1.5 rounded-md hover:bg-surface transition",
|
||||
link.has_password ? "text-accent" : "text-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
// On the ICON, not the button: `clsx` does not merge Tailwind, so a colour className loses
|
||||
// to the variant's own `text-muted`/`hover:text-fg` by stylesheet order and the state was
|
||||
// simply invisible (E10 review round 2). The Copy button above already does it this way.
|
||||
icon={<Lock className={`w-4 h-4 ${link.has_password ? "text-accent" : ""}`} />}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={async () => {
|
||||
if (
|
||||
await confirm({
|
||||
@@ -174,11 +182,9 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
||||
)
|
||||
revoke.mutate();
|
||||
}}
|
||||
title={t("downloads.share.revoke")}
|
||||
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-danger transition"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
label={t("downloads.share.revoke")}
|
||||
icon={<Trash2 className="w-4 h-4 text-danger" />}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted flex-wrap">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
|
||||
@@ -11,12 +11,12 @@ import TagManager from "./TagManager";
|
||||
import SavedViewsPopover from "./SavedViewsPopover";
|
||||
import { useSavedViews } from "./SavedViewsProvider";
|
||||
import FilterRail from "./FilterRail";
|
||||
import { type PanelItem } from "./PanelGroups";
|
||||
import { type PanelItem } from "./ui/PanelGroups";
|
||||
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||
import ChannelPicker from "./ChannelPicker";
|
||||
import DateFilterSelect from "./DateFilterSelect";
|
||||
import YearFilter from "./YearFilter";
|
||||
import Combobox, { type ComboboxOption } from "./Combobox";
|
||||
import Combobox, { type ComboboxOption } from "./ui/Combobox";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
import { useChannelsActions } from "./ChannelsProvider";
|
||||
import { useMe } from "../lib/useMe";
|
||||
|
||||
@@ -6,11 +6,13 @@ import { History, Pause, Play, RefreshCw } from "lucide-react";
|
||||
import { api, type AdminQuotaRow, type Me } from "../lib/api";
|
||||
import { formatEta, quotaActionLabel } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { useWizard } from "./WizardProvider";
|
||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import FilterChipGroup from "./FilterChipGroup";
|
||||
import Tabs from "./Tabs";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import { Section, SettingRow } from "./ui/form";
|
||||
import { useMe } from "../lib/useMe";
|
||||
|
||||
@@ -51,6 +53,8 @@ export default function Stats() {
|
||||
function Overview({ me }: { me: Me }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
// The 403 case needs somewhere to go, or the toast names a fix the user cannot reach from here.
|
||||
const { openWizard } = useWizard();
|
||||
const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
|
||||
const usage = useQuery({ queryKey: qk.myUsage(), queryFn: api.myUsage });
|
||||
const s = status.data;
|
||||
@@ -64,7 +68,12 @@ function Overview({ me }: { me: Me }) {
|
||||
message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }),
|
||||
});
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("stats.sync.syncFailed") }),
|
||||
// The same helper and the same wizard handle as the Channels page: it translates the 403 into
|
||||
// "connect YouTube" WITH a button that opens the wizard — instead of surfacing the route's bare
|
||||
// English detail under a Hungarian UI — and delegates everything else to notifyActionError so
|
||||
// the server's own reason wins, which is what makes the SEC-14 cooldown read as a pace limit
|
||||
// rather than "Sync failed".
|
||||
onError: (e) => notifyYouTubeActionError(e, t("stats.sync.syncFailed"), openWizard),
|
||||
});
|
||||
const deepAll = useMutation({
|
||||
mutationFn: () => api.deepAll(true),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
|
||||
import { api, type MyStatus } from "../lib/api";
|
||||
import { formatViews } from "../lib/format";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
// Per-user status (not the global catalog): shows the number of videos available to *this*
|
||||
// user, how many of *their* channels are still being fetched, and how many lack full
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { filterChipClass } from "./FilterChipGroup";
|
||||
import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { filterChipClass } from "./ui/FilterChipGroup";
|
||||
import { useAnchoredPopover, PopoverSurface } from "./ui/AnchoredPopover";
|
||||
|
||||
// A single-line row of toggle filter chips that collapses its overflow into a trailing "+N" pill whose
|
||||
// popover holds the rest (still toggleable) — the same compression the feed's applied-filter row uses
|
||||
@@ -74,7 +74,7 @@ export default function TagFilterChips({
|
||||
const moreLabel = t("channels.tags.moreCount", { count: hidden });
|
||||
|
||||
return (
|
||||
<div className={`relative flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
|
||||
<div className={`${chipLaneClass} flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
|
||||
{/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can
|
||||
read each chip's right edge, the rem gap and the pill width off real boxes. */}
|
||||
<div
|
||||
|
||||
@@ -6,9 +6,10 @@ import { Plus, Trash2 } from "lucide-react";
|
||||
import { api, type Tag } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import Modal from "./Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
// One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a
|
||||
// hover popover on the count listing the tagged channels (each a link that focuses it in the
|
||||
@@ -203,13 +204,15 @@ export default function TagManager({
|
||||
placeholder={t("tagManager.newPlaceholder")}
|
||||
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={!newName.trim()}
|
||||
className="shrink-0 px-3 py-1.5 rounded-md border border-border text-sm text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-40 transition"
|
||||
>
|
||||
{t("tagManager.add")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { UploadCloud, File as FileIcon } from "lucide-react";
|
||||
import Modal from "./Modal";
|
||||
import Modal from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import { api } from "../lib/api";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user