build(deps): migrate Tailwind CSS v3.4 → v4
Root-fixes the opacity-modifier seed bug behind R10 S0: the theme colors were bare `var(--x)` hex with no <alpha-value>, so `bg-accent/40`, `ring-accent/40`, `border-border/60`, `text-muted/70`, … (~99 sites across 45 files) emitted no CSS rule at all — latent no-ops. v4 implements the opacity modifier via `color-mix`, so the modifier now works with the existing hex vars, no per-scheme token duplication needed. - `npx @tailwindcss/upgrade`: `@tailwind` directives → `@import "tailwindcss"`, JS config → CSS `@theme` (colors, --font-sans, the z-index tokens preserved), classname renames (rounded/shadow/blur scale, outline-none→outline-hidden, ring→ring-2) across 56 files; PostCSS plugin → @tailwindcss/postcss, autoprefixer removed (v4 prefixes for you). - `@theme` → `@theme inline` (load-bearing): the color vars are redefined at runtime under html[data-scheme]/[data-theme]; inline resolves them at the use site so per-scheme opacity tints track the active scheme (the shadcn v4 pattern). - Revert one codemod false-positive: it rewrote the English word "rounded" → "rounded-sm" inside a releaseNotes.ts prose sentence. Gate green: tsc(app+node) / eslint 0-err / prettier / vitest 79 / knip. Verified in-browser: bg-accent/15 now computes a scheme-tracking color at 0.15 alpha across all 6 schemes × 2 themes; the hand-rolled glass border/shadow/ radius/backdrop-filter render unchanged under v4.
This commit is contained in:
Generated
+604
-591
File diff suppressed because it is too large
Load Diff
@@ -37,12 +37,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "9.39.1",
|
"@eslint/js": "9.39.1",
|
||||||
"@playwright/test": "^1.61.1",
|
"@playwright/test": "^1.61.1",
|
||||||
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^26.1.1",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"autoprefixer": "^10.4.19",
|
|
||||||
"eslint": "9.39.1",
|
"eslint": "9.39.1",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-react-hooks": "5.2.0",
|
"eslint-plugin-react-hooks": "5.2.0",
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"knip": "6.27.0",
|
"knip": "6.27.0",
|
||||||
"postcss": "^8.4.39",
|
"postcss": "^8.4.39",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
"tailwindcss": "^3.4.6",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"typescript-eslint": "8.46.0",
|
"typescript-eslint": "8.46.0",
|
||||||
"vite": "^5.3.4",
|
"vite": "^5.3.4",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
"@tailwindcss/postcss": {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,19 +45,19 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
|
|||||||
ref={pop.refs.setReference}
|
ref={pop.refs.setReference}
|
||||||
{...pop.getReferenceProps()}
|
{...pop.getReferenceProps()}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="block max-w-[22rem] truncate text-muted cursor-help outline-none"
|
className="block max-w-88 truncate text-muted cursor-help outline-hidden"
|
||||||
>
|
>
|
||||||
{preview}
|
{preview}
|
||||||
</span>
|
</span>
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={pop}
|
popover={pop}
|
||||||
className="glass w-[480px] max-h-[28rem] overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
|
className="glass w-[480px] max-h-112 overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
|
||||||
>
|
>
|
||||||
{/* Full About once the detail loads; until then the list's truncated description. */}
|
{/* Full About once the detail loads; until then the list's truncated description. */}
|
||||||
{ch ? (
|
{ch ? (
|
||||||
<ChannelAboutContent ch={ch} />
|
<ChannelAboutContent ch={ch} />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{preview}</p>
|
<p className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">{preview}</p>
|
||||||
)}
|
)}
|
||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function ActiveFilterChips({
|
|||||||
aria-label={t("feed.chips.remove", { name: f.label })}
|
aria-label={t("feed.chips.remove", { name: f.label })}
|
||||||
className="group inline-flex items-center gap-1 pl-2.5 pr-1.5 py-1 rounded-full border border-accent/40 bg-accent/10 text-accent text-xs hover:border-accent hover:bg-accent/20 transition"
|
className="group inline-flex items-center gap-1 pl-2.5 pr-1.5 py-1 rounded-full border border-accent/40 bg-accent/10 text-accent text-xs hover:border-accent hover:bg-accent/20 transition"
|
||||||
>
|
>
|
||||||
<span className="max-w-[14rem] truncate">{f.label}</span>
|
<span className="max-w-56 truncate">{f.label}</span>
|
||||||
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
|
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export default function AddToPlaylist({
|
|||||||
// the floating element only). `toggle()` already no-ops while busy, so re-entrancy is
|
// the floating element only). `toggle()` already no-ops while busy, so re-entrancy is
|
||||||
// guarded without touching focus; `aria-busy` on the list conveys the pending state.
|
// guarded without touching focus; `aria-busy` on the list conveys the pending state.
|
||||||
label={playlistName(pl, t)}
|
label={playlistName(pl, t)}
|
||||||
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-none transition hover:bg-card/60 data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)]"
|
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition hover:bg-card/60 data-active:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)]"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`grid place-items-center w-4 h-4 rounded border ${
|
className={`grid place-items-center w-4 h-4 rounded border ${
|
||||||
@@ -156,7 +156,7 @@ export default function AddToPlaylist({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={t("playlists.newPlaylist")}
|
placeholder={t("playlists.newPlaylist")}
|
||||||
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ function AdminInvites() {
|
|||||||
value={newEmail}
|
value={newEmail}
|
||||||
onChange={(e) => setNewEmail(e.target.value)}
|
onChange={(e) => setNewEmail(e.target.value)}
|
||||||
placeholder={t("settings.invites.addPlaceholder")}
|
placeholder={t("settings.invites.addPlaceholder")}
|
||||||
className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
|
className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -468,7 +468,7 @@ function AdminDemo() {
|
|||||||
value={newEmail}
|
value={newEmail}
|
||||||
onChange={(e) => setNewEmail(e.target.value)}
|
onChange={(e) => setNewEmail(e.target.value)}
|
||||||
placeholder={t("settings.demo.addPlaceholder")}
|
placeholder={t("settings.demo.addPlaceholder")}
|
||||||
className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
|
className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ type ItemRole = "menuitem" | "menuitemradio" | "menuitemcheckbox" | "option";
|
|||||||
* key. The recommended recipe is an accent tint — and note `bg-accent/NN` does NOT work here (the
|
* key. The recommended recipe is an accent tint — and note `bg-accent/NN` does NOT work here (the
|
||||||
* theme's `--accent` is a hex var, and Tailwind v3 can't inject alpha into it), so use a color-mix
|
* theme's `--accent` is a hex var, and Tailwind v3 can't inject alpha into it), so use a color-mix
|
||||||
* arbitrary value like the app's own glass idiom:
|
* arbitrary value like the app's own glass idiom:
|
||||||
* `data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-[active]:text-fg`. */
|
* `data-active:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-active:text-fg`. */
|
||||||
export function PopoverItem({
|
export function PopoverItem({
|
||||||
children,
|
children,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{ch.description ? (
|
{ch.description ? (
|
||||||
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">
|
<p className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
|
||||||
{linkify(ch.description)}
|
{linkify(ch.description)}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -81,7 +81,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
|
|||||||
{/^[A-Za-z]{2}$/.test(ch.country) && (
|
{/^[A-Za-z]{2}$/.test(ch.country) && (
|
||||||
// Real SVG flag (flag-icons) — Windows/Chrome don't render flag emoji.
|
// Real SVG flag (flag-icons) — Windows/Chrome don't render flag emoji.
|
||||||
<span
|
<span
|
||||||
className={`fi fi-${ch.country.toLowerCase()} rounded-sm shrink-0`}
|
className={`fi fi-${ch.country.toLowerCase()} rounded-xs shrink-0`}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ export default function ChannelDiscovery({
|
|||||||
<>
|
<>
|
||||||
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
|
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
|
||||||
<PageToolbar>
|
<PageToolbar>
|
||||||
<div className="px-4 pt-3 max-w-[96rem] mx-auto flex items-start justify-between gap-4">
|
<div className="px-4 pt-3 max-w-384 mx-auto flex items-start justify-between gap-4">
|
||||||
<p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
|
<p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
|
||||||
{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
|
{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
|
||||||
{layout === "cards" && (
|
{layout === "cards" && (
|
||||||
@@ -224,7 +224,7 @@ export default function ChannelDiscovery({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
<div className="px-4 pb-4 max-w-[96rem] mx-auto">
|
<div className="px-4 pb-4 max-w-384 mx-auto">
|
||||||
{query.isError && !query.data ? (
|
{query.isError && !query.data ? (
|
||||||
<StateMessage tone="error" onRetry={() => query.refetch()}>
|
<StateMessage tone="error" onRetry={() => query.refetch()}>
|
||||||
{t("common.loadError")}
|
{t("common.loadError")}
|
||||||
@@ -239,7 +239,7 @@ export default function ChannelDiscovery({
|
|||||||
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
|
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
|
||||||
controlsPosition="both"
|
controlsPosition="both"
|
||||||
controlsInBand
|
controlsInBand
|
||||||
controlsBandClassName="px-4 max-w-[96rem] mx-auto"
|
controlsBandClassName="px-4 max-w-384 mx-auto"
|
||||||
sort={sort}
|
sort={sort}
|
||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
emptyText={t("channels.discovery.empty")}
|
emptyText={t("channels.discovery.empty")}
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ export default function ChannelPage({
|
|||||||
<button
|
<button
|
||||||
data-testid="channel-back"
|
data-testid="channel-back"
|
||||||
onClick={closeChannel}
|
onClick={closeChannel}
|
||||||
className="absolute top-5 left-7 sm:left-9 z-base inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-sm"
|
className="absolute top-5 left-7 sm:left-9 z-base inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-xs"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
{t("channel.back")}
|
{t("channel.back")}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function ChannelPicker({
|
|||||||
key={id}
|
key={id}
|
||||||
onClick={() => toggle(id)}
|
onClick={() => toggle(id)}
|
||||||
title={t("sidebar.channelRemove", { name: nameOf(id) })}
|
title={t("sidebar.channelRemove", { name: nameOf(id) })}
|
||||||
className="inline-flex items-center gap-1 max-w-full pl-2.5 pr-1.5 py-1 rounded-full bg-accent text-accent-fg text-xs shadow-sm hover:opacity-90 transition"
|
className="inline-flex items-center gap-1 max-w-full pl-2.5 pr-1.5 py-1 rounded-full bg-accent text-accent-fg text-xs shadow-xs hover:opacity-90 transition"
|
||||||
>
|
>
|
||||||
<span className="truncate">{nameOf(id)}</span>
|
<span className="truncate">{nameOf(id)}</span>
|
||||||
<X className="w-3 h-3 shrink-0" />
|
<X className="w-3 h-3 shrink-0" />
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ interface TagChipProps {
|
|||||||
|
|
||||||
function TagChip({ tag, onClick, title }: TagChipProps) {
|
function TagChip({ tag, onClick, title }: TagChipProps) {
|
||||||
const cls =
|
const cls =
|
||||||
"shrink-0 text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent max-w-[9rem] truncate";
|
"shrink-0 text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent max-w-36 truncate";
|
||||||
return onClick ? (
|
return onClick ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -75,7 +75,7 @@ function TagEditorMenu({
|
|||||||
checked={on}
|
checked={on}
|
||||||
onSelect={() => onToggle(tg.id)}
|
onSelect={() => onToggle(tg.id)}
|
||||||
label={tg.name}
|
label={tg.name}
|
||||||
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-none transition hover:bg-card data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-[active]:text-fg ${
|
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition hover:bg-card data-active:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-active:text-fg ${
|
||||||
on ? "text-fg" : "text-muted"
|
on ? "text-fg" : "text-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -111,7 +111,7 @@ function TagEditorMenu({
|
|||||||
}}
|
}}
|
||||||
maxLength={64}
|
maxLength={64}
|
||||||
placeholder={t("channels.tags.createPlaceholder")}
|
placeholder={t("channels.tags.createPlaceholder")}
|
||||||
className="flex-1 min-w-0 bg-transparent text-xs px-2 py-1 rounded-md border border-border focus:border-accent outline-none"
|
className="flex-1 min-w-0 bg-transparent text-xs px-2 py-1 rounded-md border border-border focus:border-accent outline-hidden"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -247,7 +247,7 @@ export function ChannelTagsControl({
|
|||||||
// collapses the rest into "+N". (max-w-full lets a narrower card shrink it.) Unbounded, the row
|
// 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.
|
// takes its container's width, so a wide header shows the tags inline instead of collapsing them.
|
||||||
<div
|
<div
|
||||||
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-[12rem] max-w-full" : "w-full"}`}
|
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
|
||||||
>
|
>
|
||||||
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. */}
|
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -616,7 +616,7 @@ export default function Channels() {
|
|||||||
const cardPager = <Pager {...pagerProps} />;
|
const cardPager = <Pager {...pagerProps} />;
|
||||||
|
|
||||||
const tabs = (
|
const tabs = (
|
||||||
<div className="px-4 pt-4 max-w-[96rem] mx-auto flex items-center justify-between gap-4 flex-wrap">
|
<div className="px-4 pt-4 max-w-384 mx-auto flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
|
{(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
|
||||||
<button
|
<button
|
||||||
@@ -648,7 +648,7 @@ export default function Channels() {
|
|||||||
<PageToolbar>
|
<PageToolbar>
|
||||||
{tabs}
|
{tabs}
|
||||||
{view === "subscribed" && (
|
{view === "subscribed" && (
|
||||||
<div className="px-4 pt-2 pb-2 max-w-[96rem] mx-auto">
|
<div className="px-4 pt-2 pb-2 max-w-384 mx-auto">
|
||||||
{/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name
|
{/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name
|
||||||
search lives in the shared top SearchBar, tag create/delete in the manager. */}
|
search lives in the shared top SearchBar, tag create/delete in the manager. */}
|
||||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
@@ -700,7 +700,7 @@ export default function Channels() {
|
|||||||
{/* In table mode the status chips + pager ride in DataTable's own in-band controls row. The card
|
{/* In table mode the status chips + pager ride in DataTable's own in-band controls row. The card
|
||||||
layout has no such row, so surface the same status chips (left) and its pager (right) here. */}
|
layout has no such row, so surface the same status chips (left) and its pager (right) here. */}
|
||||||
{view === "subscribed" && layout === "cards" && (
|
{view === "subscribed" && layout === "cards" && (
|
||||||
<div className="px-4 pb-2 max-w-[96rem] mx-auto flex items-center justify-between gap-3 flex-wrap">
|
<div className="px-4 pb-2 max-w-384 mx-auto flex items-center justify-between gap-3 flex-wrap">
|
||||||
{statusChips}
|
{statusChips}
|
||||||
{cardPager}
|
{cardPager}
|
||||||
</div>
|
</div>
|
||||||
@@ -719,7 +719,7 @@ export default function Channels() {
|
|||||||
/>
|
/>
|
||||||
) : view === "blocked" ? (
|
) : view === "blocked" ? (
|
||||||
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
|
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
|
||||||
<div className="px-4 pb-4 pt-3 max-w-[96rem] mx-auto">
|
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
|
||||||
{(blockedQuery.data?.length ?? 0) === 0 ? (
|
{(blockedQuery.data?.length ?? 0) === 0 ? (
|
||||||
<p className="text-sm text-muted py-8">{t("channels.blocked.empty")}</p>
|
<p className="text-sm text-muted py-8">{t("channels.blocked.empty")}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -756,7 +756,7 @@ export default function Channels() {
|
|||||||
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
|
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
|
||||||
`position: sticky` engages on the first scroll (the band's controls row above gives the
|
`position: sticky` engages on the first scroll (the band's controls row above gives the
|
||||||
visual gap). */
|
visual gap). */
|
||||||
<div className="px-4 pb-4 max-w-[96rem] mx-auto">
|
<div className="px-4 pb-4 max-w-384 mx-auto">
|
||||||
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row
|
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row
|
||||||
(status chips + pager) rides in the fixed band right above it (controlsInBand). The
|
(status chips + pager) rides in the fixed band right above it (controlsInBand). The
|
||||||
card layout swaps the table for a virtualized grid over the same rows (its pager and
|
card layout swaps the table for a virtualized grid over the same rows (its pager and
|
||||||
@@ -776,7 +776,7 @@ export default function Channels() {
|
|||||||
controlsPosition="both"
|
controlsPosition="both"
|
||||||
controlsLeading={statusChips}
|
controlsLeading={statusChips}
|
||||||
controlsInBand
|
controlsInBand
|
||||||
controlsBandClassName="px-4 max-w-[96rem] mx-auto"
|
controlsBandClassName="px-4 max-w-384 mx-auto"
|
||||||
sort={sort}
|
sort={sort}
|
||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
||||||
@@ -953,7 +953,7 @@ function PriorityCell({
|
|||||||
else if (e.key === "Escape") setEditing(false);
|
else if (e.key === "Escape") setEditing(false);
|
||||||
}}
|
}}
|
||||||
aria-label={t("channels.row.priorityHint")}
|
aria-label={t("channels.row.priorityHint")}
|
||||||
className="absolute top-1/2 right-0 -translate-y-1/2 w-14 h-6 text-sm font-medium tabular-nums text-right bg-card border border-accent rounded px-1 outline-none z-popover [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
className="absolute top-1/2 right-0 -translate-y-1/2 w-14 h-6 text-sm font-medium tabular-nums text-right bg-card border border-accent rounded-sm px-1 outline-hidden z-popover [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ export default function ChatThread({
|
|||||||
return (
|
return (
|
||||||
<div key={m.id} className={`flex ${mine ? "justify-end" : "justify-start"}`}>
|
<div key={m.id} className={`flex ${mine ? "justify-end" : "justify-start"}`}>
|
||||||
<div
|
<div
|
||||||
className={`max-w-[80%] rounded-2xl px-3 py-2 text-sm whitespace-pre-wrap break-words ${
|
className={`max-w-[80%] rounded-2xl px-3 py-2 text-sm whitespace-pre-wrap wrap-break-word ${
|
||||||
mine ? "bg-accent text-accent-fg rounded-br-sm" : "bg-card rounded-bl-sm"
|
mine ? "bg-accent text-accent-fg rounded-br-sm" : "bg-card rounded-bl-sm"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -211,7 +211,7 @@ export default function ChatThread({
|
|||||||
}}
|
}}
|
||||||
rows={1}
|
rows={1}
|
||||||
placeholder={t("messages.writePlaceholder")}
|
placeholder={t("messages.writePlaceholder")}
|
||||||
className="flex-1 resize-none max-h-28 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
|
className="flex-1 resize-none max-h-28 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function ColumnSortControl<T>({
|
|||||||
onChange(key ? { key, dir: active?.key === key ? active.dir : "asc" } : null);
|
onChange(key ? { key, dir: active?.key === key ? active.dir : "asc" } : null);
|
||||||
}}
|
}}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
|
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-hidden focus:border-accent"
|
||||||
>
|
>
|
||||||
<option value="">{defaultLabel}</option>
|
<option value="">{defaultLabel}</option>
|
||||||
{sortable.map((c) => (
|
{sortable.map((c) => (
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ function Field({
|
|||||||
max={isNum && item.max != null ? item.max : undefined}
|
max={isNum && item.max != null ? item.max : undefined}
|
||||||
placeholder={item.secret ? "••••••••" : String(item.default ?? "")}
|
placeholder={item.secret ? "••••••••" : String(item.default ?? "")}
|
||||||
aria-label={t(`config.fields.${item.key}.label`, item.key)}
|
aria-label={t(`config.fields.${item.key}.label`, item.key)}
|
||||||
className="w-52 bg-card border border-border rounded-xl px-3 py-1.5 text-sm outline-none focus:border-accent disabled:opacity-50"
|
className="w-52 bg-card border border-border rounded-xl px-3 py-1.5 text-sm outline-hidden focus:border-accent disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showReset && !secretDisabled && (
|
{showReset && !secretDisabled && (
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ function SourceRef({ url }: { url: string }) {
|
|||||||
onClick={copy}
|
onClick={copy}
|
||||||
aria-label={t("downloads.source.copy")}
|
aria-label={t("downloads.source.copy")}
|
||||||
title={t("downloads.source.copy")}
|
title={t("downloads.source.copy")}
|
||||||
className="shrink-0 p-0.5 rounded hover:bg-surface hover:text-fg transition"
|
className="shrink-0 p-0.5 rounded-sm hover:bg-surface hover:text-fg transition"
|
||||||
>
|
>
|
||||||
<Copy className="w-3 h-3" />
|
<Copy className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -227,7 +227,7 @@ function DownloadRow({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
|
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
|
||||||
{job.job_kind === "edit" && (
|
{job.job_kind === "edit" && (
|
||||||
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-accent/15 text-accent">
|
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded-sm text-[10px] font-semibold uppercase tracking-wide bg-accent/15 text-accent">
|
||||||
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
|
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -435,7 +435,7 @@ function ErrorModal({
|
|||||||
{t("downloads.errors.fail.detailsToggle")}
|
{t("downloads.errors.fail.detailsToggle")}
|
||||||
</button>
|
</button>
|
||||||
{showRaw && (
|
{showRaw && (
|
||||||
<pre className="mt-1.5 text-xs bg-surface rounded-lg p-2.5 overflow-x-auto whitespace-pre-wrap break-words max-h-40 text-muted">
|
<pre className="mt-1.5 text-xs bg-surface rounded-lg p-2.5 overflow-x-auto whitespace-pre-wrap wrap-break-word max-h-40 text-muted">
|
||||||
{job.error}
|
{job.error}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
@@ -713,7 +713,7 @@ function AdminSystem() {
|
|||||||
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
|
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
|
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
|
||||||
className="p-1 rounded hover:bg-surface text-muted hover:text-fg"
|
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-fg"
|
||||||
title={t("downloads.admin.editQuota")}
|
title={t("downloads.admin.editQuota")}
|
||||||
>
|
>
|
||||||
<Settings2 className="w-4 h-4" />
|
<Settings2 className="w-4 h-4" />
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ export default function Feed({
|
|||||||
value={ytCount}
|
value={ytCount}
|
||||||
onChange={(e) => setYtCount(Number(e.target.value))}
|
onChange={(e) => setYtCount(Number(e.target.value))}
|
||||||
title={t("feed.yt.showHint")}
|
title={t("feed.yt.showHint")}
|
||||||
className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
|
className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-hidden focus:border-accent"
|
||||||
>
|
>
|
||||||
{[20, 40, 60, 100].map((n) => (
|
{[20, 40, 60, 100].map((n) => (
|
||||||
<option key={n} value={n}>
|
<option key={n} value={n}>
|
||||||
@@ -603,7 +603,7 @@ export default function Feed({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
title={t("feed.source.tip")}
|
title={t("feed.source.tip")}
|
||||||
className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
|
className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-hidden focus:border-accent"
|
||||||
>
|
>
|
||||||
<option value="organic">{t("feed.source.organic")}</option>
|
<option value="organic">{t("feed.source.organic")}</option>
|
||||||
<option value="all">{t("feed.source.all")}</option>
|
<option value="all">{t("feed.source.all")}</option>
|
||||||
@@ -637,7 +637,7 @@ export default function Feed({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
aria-label={t("feed.sortLabel")}
|
aria-label={t("feed.sortLabel")}
|
||||||
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
|
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-hidden focus:border-accent"
|
||||||
>
|
>
|
||||||
{/* Relevance only ranks meaningfully when there's a search term — offer it then. */}
|
{/* Relevance only ranks meaningfully when there's a search term — offer it then. */}
|
||||||
{(filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys).map((k) => (
|
{(filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys).map((k) => (
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ export default function GlassTuner() {
|
|||||||
onClick={() => setTab(t)}
|
onClick={() => setTab(t)}
|
||||||
className={`flex-1 rounded-lg px-2 py-1 text-xs capitalize ${
|
className={`flex-1 rounded-lg px-2 py-1 text-xs capitalize ${
|
||||||
tab === t
|
tab === t
|
||||||
? "bg-accent text-[color:var(--accent-fg)] font-semibold"
|
? "bg-accent text-(--accent-fg) font-semibold"
|
||||||
: "glass-card text-muted"
|
: "glass-card text-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -361,7 +361,7 @@ export default function GlassTuner() {
|
|||||||
type="color"
|
type="color"
|
||||||
value={swatches[p.k] || "#000000"}
|
value={swatches[p.k] || "#000000"}
|
||||||
onChange={(e) => setColor(p.k, e.target.value)}
|
onChange={(e) => setColor(p.k, e.target.value)}
|
||||||
className="w-8 h-6 rounded border border-border bg-transparent cursor-pointer"
|
className="w-8 h-6 rounded-sm border border-border bg-transparent cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<span className="flex-1">{p.label}</span>
|
<span className="flex-1">{p.label}</span>
|
||||||
<code className="text-[10px] text-muted">{swatches[p.k]}</code>
|
<code className="text-[10px] text-muted">{swatches[p.k]}</code>
|
||||||
@@ -385,7 +385,7 @@ export default function GlassTuner() {
|
|||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={copy}
|
onClick={copy}
|
||||||
className="flex-1 rounded-lg px-2 py-1.5 text-xs font-semibold bg-accent text-[color:var(--accent-fg)]"
|
className="flex-1 rounded-lg px-2 py-1.5 text-xs font-semibold bg-accent text-(--accent-fg)"
|
||||||
>
|
>
|
||||||
{copied ? "Copied ✓" : "Copy CSS"}
|
{copied ? "Copied ✓" : "Copy CSS"}
|
||||||
</button>
|
</button>
|
||||||
@@ -396,7 +396,7 @@ export default function GlassTuner() {
|
|||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<pre className="text-[9.5px] leading-relaxed bg-[color:var(--bg)] border border-border/60 rounded-lg p-2 overflow-x-auto text-muted whitespace-pre">
|
<pre className="text-[9.5px] leading-relaxed bg-(--bg) border border-border/60 rounded-lg p-2 overflow-x-auto text-muted whitespace-pre">
|
||||||
{cssOut}
|
{cssOut}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -419,7 +419,7 @@ function Ctl({ s, value, onChange }: { s: Slider; value: number; onChange: (v: n
|
|||||||
step={s.step}
|
step={s.step}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
className="w-16 text-right text-[11px] tabular-nums rounded bg-card border border-border px-1.5 py-0.5 text-accent"
|
className="w-16 text-right text-[11px] tabular-nums rounded-sm bg-card border border-border px-1.5 py-0.5 text-accent"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { api, HttpError } from "../lib/api";
|
|||||||
import * as e2ee from "../lib/e2ee";
|
import * as e2ee from "../lib/e2ee";
|
||||||
|
|
||||||
const inputCls =
|
const inputCls =
|
||||||
"w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent";
|
"w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-hidden focus:border-accent";
|
||||||
|
|
||||||
// Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device.
|
// Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device.
|
||||||
// Self-contained — on success it updates the shared e2ee unlock state, so any observing view
|
// Self-contained — on success it updates the shared e2ee unlock state, so any observing view
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default function LanguageSwitcher({
|
|||||||
{rail ? (
|
{rail ? (
|
||||||
/* Current-language badge (HU/EN) so the active language reads at a glance without
|
/* Current-language badge (HU/EN) so the active language reads at a glance without
|
||||||
opening the menu. The ring matches the page background to detach it from the icon. */
|
opening the menu. The ring matches the page background to detach it from the icon. */
|
||||||
<span className="absolute -bottom-1 -right-1 text-[8px] font-bold leading-none px-1 py-[1px] rounded bg-accent text-accent-fg ring-2 ring-bg">
|
<span className="absolute -bottom-1 -right-1 text-[8px] font-bold leading-none px-1 py-px rounded-sm bg-accent text-accent-fg ring-2 ring-bg">
|
||||||
{current.code.toUpperCase()}
|
{current.code.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -78,7 +78,7 @@ export default function LanguageSwitcher({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
label={l.label}
|
label={l.label}
|
||||||
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted outline-none transition hover:text-fg hover:bg-card data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-[active]:text-fg"
|
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted outline-hidden transition hover:text-fg hover:bg-card data-active:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-active:text-fg"
|
||||||
>
|
>
|
||||||
<span>{l.label}</span>
|
<span>{l.label}</span>
|
||||||
{l.code === value && <Check className="w-4 h-4 text-accent" />}
|
{l.code === value && <Check className="w-4 h-4 text-accent" />}
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
|
|||||||
value={filter}
|
value={filter}
|
||||||
onChange={(e) => setFilter(e.target.value)}
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
placeholder={t("messages.searchPeople")}
|
placeholder={t("messages.searchPeople")}
|
||||||
className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
|
className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
{q.isError && !q.data ? (
|
{q.isError && !q.data ? (
|
||||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default function Modal({
|
|||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-overlay flex items-center justify-center p-4 sm:p-6 bg-black/70 backdrop-blur-sm"
|
className="fixed inset-0 z-overlay flex items-center justify-center p-4 sm:p-6 bg-black/70 backdrop-blur-xs"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
pressedOnBackdrop.current = e.target === e.currentTarget;
|
pressedOnBackdrop.current = e.target === e.currentTarget;
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -345,7 +345,7 @@ function ClientActivityRow({
|
|||||||
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
|
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{n.title && <div className="text-sm font-semibold">{n.title}</div>}
|
{n.title && <div className="text-sm font-semibold">{n.title}</div>}
|
||||||
<div className="text-sm break-words">
|
<div className="text-sm wrap-break-word">
|
||||||
{n.message}
|
{n.message}
|
||||||
{n.repeat > 1 && (
|
{n.repeat > 1 && (
|
||||||
<span className="ml-1.5 align-middle text-[11px] font-semibold text-muted tabular-nums">
|
<span className="ml-1.5 align-middle text-[11px] font-semibold text-muted tabular-nums">
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default function PageScroller({
|
|||||||
<main
|
<main
|
||||||
ref={setRef}
|
ref={setRef}
|
||||||
style={fade.style}
|
style={fade.style}
|
||||||
className="flex-1 min-w-0 overflow-y-auto [scrollbar-gutter:stable]"
|
className="flex-1 min-w-0 overflow-y-auto scrollbar-gutter-stable"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function PageShell({
|
|||||||
return (
|
return (
|
||||||
<PageToolbarSlot.Provider value={toolbarSlot}>
|
<PageToolbarSlot.Provider value={toolbarSlot}>
|
||||||
{header}
|
{header}
|
||||||
<div className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-[var(--hdr-h)]" : ""}`}>
|
<div className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}>
|
||||||
{banners}
|
{banners}
|
||||||
<div ref={setToolbarSlot} className="shrink-0" />
|
<div ref={setToolbarSlot} className="shrink-0" />
|
||||||
<PageScroller scrollKey={scrollKey} fadeTop={fadeTop}>
|
<PageScroller scrollKey={scrollKey} fadeTop={fadeTop}>
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export default function Pager({
|
|||||||
onBlur={commitPageInput}
|
onBlur={commitPageInput}
|
||||||
onKeyDown={(e) => e.key === "Enter" && commitPageInput()}
|
onKeyDown={(e) => e.key === "Enter" && commitPageInput()}
|
||||||
aria-label={t("datatable.pager.pageLabel")}
|
aria-label={t("datatable.pager.pageLabel")}
|
||||||
className="w-14 bg-card border border-border rounded-md px-1.5 py-1 text-xs text-center tabular-nums outline-none focus:border-accent"
|
className="w-14 bg-card border border-border rounded-md px-1.5 py-1 text-xs text-center tabular-nums outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
{t("datatable.pager.ofTotal", { total: totalPages })}
|
{t("datatable.pager.ofTotal", { total: totalPages })}
|
||||||
</span>
|
</span>
|
||||||
@@ -110,7 +110,7 @@ export default function Pager({
|
|||||||
onSizeChange(Number(e.target.value));
|
onSizeChange(Number(e.target.value));
|
||||||
scrollListToTop();
|
scrollListToTop();
|
||||||
}}
|
}}
|
||||||
className="bg-card border border-border rounded-md px-1.5 py-1 text-xs outline-none focus:border-accent"
|
className="bg-card border border-border rounded-md px-1.5 py-1 text-xs outline-hidden focus:border-accent"
|
||||||
>
|
>
|
||||||
{pageSizeOptions.map((n) => (
|
{pageSizeOptions.map((n) => (
|
||||||
<option key={n} value={n}>
|
<option key={n} value={n}>
|
||||||
|
|||||||
@@ -726,7 +726,7 @@ export default function PlayerModal({
|
|||||||
window.addEventListener("focus", rearm);
|
window.addEventListener("focus", rearm);
|
||||||
stage?.addEventListener("mouseleave", rearm);
|
stage?.addEventListener("mouseleave", rearm);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("blur", onBlur);
|
window.removeEventListener("blur-sm", onBlur);
|
||||||
window.removeEventListener("focus", rearm);
|
window.removeEventListener("focus", rearm);
|
||||||
stage?.removeEventListener("mouseleave", rearm);
|
stage?.removeEventListener("mouseleave", rearm);
|
||||||
};
|
};
|
||||||
@@ -889,7 +889,7 @@ export default function PlayerModal({
|
|||||||
<div
|
<div
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
data-testid="player-modal"
|
data-testid="player-modal"
|
||||||
className="fixed inset-0 z-overlay flex items-center justify-center p-4 sm:p-6 bg-black/80 backdrop-blur-sm"
|
className="fixed inset-0 z-overlay flex items-center justify-center p-4 sm:p-6 bg-black/80 backdrop-blur-xs"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@@ -908,7 +908,7 @@ export default function PlayerModal({
|
|||||||
disabled={index === 0}
|
disabled={index === 0}
|
||||||
aria-label={t("player.previous")}
|
aria-label={t("player.previous")}
|
||||||
title={`${t("player.previous")} · Shift+←`}
|
title={`${t("player.previous")} · Shift+←`}
|
||||||
className="absolute right-full top-0 bottom-0 mr-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-sm transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
|
className="absolute right-full top-0 bottom-0 mr-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-8 w-8" />
|
<ChevronLeft className="h-8 w-8" />
|
||||||
</button>
|
</button>
|
||||||
@@ -916,7 +916,7 @@ export default function PlayerModal({
|
|||||||
<div
|
<div
|
||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="glass-card relative flex-1 min-w-0 max-h-full overflow-y-auto rounded-2xl shadow-2xl outline-none"
|
className="glass-card relative flex-1 min-w-0 max-h-full overflow-y-auto rounded-2xl shadow-2xl outline-hidden"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -1056,7 +1056,7 @@ export default function PlayerModal({
|
|||||||
)}
|
)}
|
||||||
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
|
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
|
||||||
{playerError == null && nativeControls && (
|
{playerError == null && nativeControls && (
|
||||||
<div className="pointer-events-none absolute top-3 left-3 z-menu flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-white/90 shadow-lg backdrop-blur-sm">
|
<div className="pointer-events-none absolute top-3 left-3 z-menu flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-white/90 shadow-lg backdrop-blur-xs">
|
||||||
<Settings className="h-3.5 w-3.5" />
|
<Settings className="h-3.5 w-3.5" />
|
||||||
{t("player.nativeControls")}
|
{t("player.nativeControls")}
|
||||||
</div>
|
</div>
|
||||||
@@ -1150,7 +1150,7 @@ export default function PlayerModal({
|
|||||||
<div
|
<div
|
||||||
ref={descFade.ref}
|
ref={descFade.ref}
|
||||||
style={descFade.style}
|
style={descFade.style}
|
||||||
className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
|
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
|
||||||
>
|
>
|
||||||
{renderDescription(detail.data.description, {
|
{renderDescription(detail.data.description, {
|
||||||
currentId: currentVideoId,
|
currentId: currentVideoId,
|
||||||
@@ -1250,7 +1250,7 @@ export default function PlayerModal({
|
|||||||
<span>· {formatDuration(active.duration_seconds)}</span>
|
<span>· {formatDuration(active.duration_seconds)}</span>
|
||||||
)}
|
)}
|
||||||
{active.live_status === "was_live" && (
|
{active.live_status === "was_live" && (
|
||||||
<span className="bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
|
<span className="bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
|
||||||
{t("player.stream")}
|
{t("player.stream")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1314,7 +1314,7 @@ export default function PlayerModal({
|
|||||||
className={
|
className={
|
||||||
"shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
|
"shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
|
||||||
(watched
|
(watched
|
||||||
? "bg-accent text-accent-fg shadow-sm hover:opacity-90"
|
? "bg-accent text-accent-fg shadow-xs hover:opacity-90"
|
||||||
: "text-muted hover:text-fg hover:bg-surface border border-border")
|
: "text-muted hover:text-fg hover:bg-surface border border-border")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -1334,7 +1334,7 @@ export default function PlayerModal({
|
|||||||
disabled={index >= queue!.length - 1}
|
disabled={index >= queue!.length - 1}
|
||||||
aria-label={t("player.next")}
|
aria-label={t("player.next")}
|
||||||
title={`${t("player.next")} · Shift+→`}
|
title={`${t("player.next")} · Shift+→`}
|
||||||
className="absolute left-full top-0 bottom-0 ml-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-sm transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
|
className="absolute left-full top-0 bottom-0 ml-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-8 w-8" />
|
<ChevronRight className="h-8 w-8" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -590,7 +590,7 @@ export default function Playlists() {
|
|||||||
if (e.key === "Escape") setRenaming(false);
|
if (e.key === "Escape") setRenaming(false);
|
||||||
}}
|
}}
|
||||||
aria-label={t("playlists.rename")}
|
aria-label={t("playlists.rename")}
|
||||||
className="text-lg font-semibold bg-card border border-border rounded-md px-2 py-0.5 outline-none focus:border-accent"
|
className="text-lg font-semibold bg-card border border-border rounded-md px-2 py-0.5 outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export default function PlaylistsRail({
|
|||||||
value={plSort.key}
|
value={plSort.key}
|
||||||
onChange={(e) => setPlSort({ ...plSort, key: e.target.value as PlSort["key"] })}
|
onChange={(e) => setPlSort({ ...plSort, key: e.target.value as PlSort["key"] })}
|
||||||
aria-label={t("playlists.sortLabel")}
|
aria-label={t("playlists.sortLabel")}
|
||||||
className="flex-1 min-w-0 bg-card border border-border rounded-md px-1.5 py-1 text-[11px] outline-none focus:border-accent"
|
className="flex-1 min-w-0 bg-card border border-border rounded-md px-1.5 py-1 text-[11px] outline-hidden focus:border-accent"
|
||||||
>
|
>
|
||||||
<option value="custom">{t("playlists.railSortCustom")}</option>
|
<option value="custom">{t("playlists.railSortCustom")}</option>
|
||||||
<option value="name">{t("playlists.railSortName")}</option>
|
<option value="name">{t("playlists.railSortName")}</option>
|
||||||
@@ -200,7 +200,7 @@ export default function PlaylistsRail({
|
|||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
placeholder={t("playlists.newPlaylist")}
|
placeholder={t("playlists.newPlaylist")}
|
||||||
disabled={!canWrite}
|
disabled={!canWrite}
|
||||||
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent disabled:opacity-50"
|
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-hidden focus:border-accent disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -470,7 +470,7 @@ function PlexPosterCard({
|
|||||||
onOpen();
|
onOpen();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border cursor-pointer"
|
className="relative aspect-2/3 rounded-xl overflow-hidden bg-card border border-border cursor-pointer"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={card.thumb}
|
src={card.thumb}
|
||||||
@@ -522,18 +522,18 @@ function PlexPosterCard({
|
|||||||
)}
|
)}
|
||||||
{/* Watched badge when not hovering (the toggle replaces it on hover). */}
|
{/* Watched badge when not hovering (the toggle replaces it on hover). */}
|
||||||
{card.status === "watched" && (
|
{card.status === "watched" && (
|
||||||
<span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded group-hover:opacity-0">
|
<span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
|
||||||
{t("plex.watched")}
|
{t("plex.watched")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{card.type === "show" && card.season_count != null && (
|
{card.type === "show" && card.season_count != null && (
|
||||||
<span className="absolute bottom-1.5 left-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded">
|
<span className="absolute bottom-1.5 left-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
|
||||||
{t("plex.seasons", { count: card.season_count })}
|
{t("plex.seasons", { count: card.season_count })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* Aggregate in-progress badge for a partially-watched show. */}
|
{/* Aggregate in-progress badge for a partially-watched show. */}
|
||||||
{card.type === "show" && card.status === "in_progress" && (
|
{card.type === "show" && card.status === "in_progress" && (
|
||||||
<span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/75 text-accent px-1.5 py-0.5 rounded group-hover:opacity-0">
|
<span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/75 text-accent px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
|
||||||
{t("plex.inProgress")}
|
{t("plex.inProgress")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -545,7 +545,7 @@ function PlexPosterCard({
|
|||||||
)}
|
)}
|
||||||
{/* Type tag — tell a movie card from a show card at a glance (unified feed). */}
|
{/* Type tag — tell a movie card from a show card at a glance (unified feed). */}
|
||||||
<span
|
<span
|
||||||
className="absolute bottom-1.5 right-1.5 rounded bg-black/65 p-0.5 text-white/90"
|
className="absolute bottom-1.5 right-1.5 rounded-sm bg-black/65 p-0.5 text-white/90"
|
||||||
title={t(
|
title={t(
|
||||||
card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie",
|
card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie",
|
||||||
)}
|
)}
|
||||||
@@ -670,7 +670,7 @@ function ActionBtn({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:opacity-50 ${
|
className={`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:opacity-50 ${
|
||||||
primary ? "bg-accent text-accent-fg shadow-sm hover:opacity-90" : "glass-card glass-hover"
|
primary ? "bg-accent text-accent-fg shadow-xs hover:opacity-90" : "glass-card glass-hover"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4 shrink-0" />
|
<Icon className="w-4 h-4 shrink-0" />
|
||||||
@@ -707,7 +707,7 @@ function SeasonCard({
|
|||||||
onOpen();
|
onOpen();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border cursor-pointer"
|
className="relative aspect-2/3 rounded-xl overflow-hidden bg-card border border-border cursor-pointer"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={se.thumb}
|
src={se.thumb}
|
||||||
@@ -750,16 +750,16 @@ function SeasonCard({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{watched && (
|
{watched && (
|
||||||
<span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded group-hover:opacity-0">
|
<span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
|
||||||
{t("plex.watched")}
|
{t("plex.watched")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{se.status === "in_progress" && (
|
{se.status === "in_progress" && (
|
||||||
<span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/75 text-accent px-1.5 py-0.5 rounded group-hover:opacity-0">
|
<span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/75 text-accent px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
|
||||||
{t("plex.inProgress")}
|
{t("plex.inProgress")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="absolute bottom-1.5 left-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded">
|
<span className="absolute bottom-1.5 left-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
|
||||||
{t("plex.series.episodeCount", { count: se.episode_count })}
|
{t("plex.series.episodeCount", { count: se.episode_count })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -846,7 +846,7 @@ function RelatedStrip({ related, onOpen }: { related: PlexCard[]; onOpen: (id: s
|
|||||||
onClick={() => onOpen(r.id)}
|
onClick={() => onOpen(r.id)}
|
||||||
className="group w-[130px] shrink-0 text-left"
|
className="group w-[130px] shrink-0 text-left"
|
||||||
>
|
>
|
||||||
<div className="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border">
|
<div className="relative aspect-2/3 rounded-xl overflow-hidden bg-card border border-border">
|
||||||
<img
|
<img
|
||||||
src={r.thumb}
|
src={r.thumb}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -854,7 +854,7 @@ function RelatedStrip({ related, onOpen }: { related: PlexCard[]; onOpen: (id: s
|
|||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
/>
|
/>
|
||||||
{r.status === "watched" && (
|
{r.status === "watched" && (
|
||||||
<span className="absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded">
|
<span className="absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
|
||||||
{t("plex.watched")}
|
{t("plex.watched")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -963,7 +963,7 @@ function EpisodeCard({
|
|||||||
onClick={onAdd}
|
onClick={onAdd}
|
||||||
title={t("plex.playlist.addEpisode")}
|
title={t("plex.playlist.addEpisode")}
|
||||||
aria-label={t("plex.playlist.addEpisode")}
|
aria-label={t("plex.playlist.addEpisode")}
|
||||||
className="shrink-0 rounded p-1 text-muted transition hover:text-accent"
|
className="shrink-0 rounded-sm p-1 text-muted transition hover:text-accent"
|
||||||
>
|
>
|
||||||
<ListPlus className="w-4 h-4" />
|
<ListPlus className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1054,7 +1054,7 @@ function PlexShowView({
|
|||||||
<img
|
<img
|
||||||
src={show.thumb}
|
src={show.thumb}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full aspect-[2/3] object-cover rounded-xl border border-border"
|
className="w-full aspect-2/3 object-cover rounded-xl border border-border"
|
||||||
/>
|
/>
|
||||||
{show.resume && (
|
{show.resume && (
|
||||||
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
|
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
|
||||||
@@ -1286,7 +1286,7 @@ function PlexSeasonView({
|
|||||||
<img
|
<img
|
||||||
src={se.thumb}
|
src={se.thumb}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full aspect-[2/3] object-cover rounded-xl border border-border"
|
className="w-full aspect-2/3 object-cover rounded-xl border border-border"
|
||||||
/>
|
/>
|
||||||
{se.resume && (
|
{se.resume && (
|
||||||
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
|
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export default function PlexCollectionEditor({
|
|||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && createNew()}
|
onKeyDown={(e) => e.key === "Enter" && createNew()}
|
||||||
placeholder={t("plex.collEditor.newPlaceholder")}
|
placeholder={t("plex.collEditor.newPlaceholder")}
|
||||||
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm outline-none focus:border-accent"
|
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={createNew}
|
onClick={createNew}
|
||||||
@@ -149,7 +149,7 @@ export default function PlexCollectionEditor({
|
|||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
placeholder={t("plex.collEditor.search")}
|
placeholder={t("plex.collEditor.search")}
|
||||||
className="glass-card mb-2 w-full rounded-lg px-3 py-1.5 text-sm outline-none focus:border-accent"
|
className="glass-card mb-2 w-full rounded-lg px-3 py-1.5 text-sm outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -193,7 +193,7 @@ export default function PlexCollectionEditor({
|
|||||||
<button
|
<button
|
||||||
onClick={() => removeCollection(c.id, c.title)}
|
onClick={() => removeCollection(c.id, c.title)}
|
||||||
disabled={!!busy}
|
disabled={!!busy}
|
||||||
className="shrink-0 rounded px-1.5 py-0.5 text-xs text-muted hover:text-red-400 disabled:opacity-50"
|
className="shrink-0 rounded-sm px-1.5 py-0.5 text-xs text-muted hover:text-red-400 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t("plex.collEditor.delete")}
|
{t("plex.collEditor.delete")}
|
||||||
</button>
|
</button>
|
||||||
@@ -226,7 +226,7 @@ export default function PlexCollectionEditor({
|
|||||||
<button
|
<button
|
||||||
onClick={() => markEditable(c.id)}
|
onClick={() => markEditable(c.id)}
|
||||||
disabled={!!busy}
|
disabled={!!busy}
|
||||||
className="glass-card glass-hover shrink-0 rounded px-2 py-0.5 text-xs disabled:opacity-50"
|
className="glass-card glass-hover shrink-0 rounded-sm px-2 py-0.5 text-xs disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t("plex.collEditor.makeEditable")}
|
{t("plex.collEditor.makeEditable")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export default function PlexInfo({
|
|||||||
<img
|
<img
|
||||||
src={detail.thumb}
|
src={detail.thumb}
|
||||||
alt=""
|
alt=""
|
||||||
className={`shrink-0 rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : "w-32 sm:w-44"} aspect-[2/3]`}
|
className={`shrink-0 rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : "w-32 sm:w-44"} aspect-2/3`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
|
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
|
||||||
@@ -412,14 +412,14 @@ export default function PlexInfo({
|
|||||||
disabled={!onPlayItem}
|
disabled={!onPlayItem}
|
||||||
className="group/col w-28 shrink-0 text-left"
|
className="group/col w-28 shrink-0 text-left"
|
||||||
>
|
>
|
||||||
<div className="relative aspect-[2/3] overflow-hidden rounded-lg border border-border bg-surface">
|
<div className="relative aspect-2/3 overflow-hidden rounded-lg border border-border bg-surface">
|
||||||
<img
|
<img
|
||||||
src={m.thumb}
|
src={m.thumb}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-full w-full object-cover transition group-hover/col:scale-105"
|
className="h-full w-full object-cover transition group-hover/col:scale-105"
|
||||||
/>
|
/>
|
||||||
{m.status === "watched" && (
|
{m.status === "watched" && (
|
||||||
<span className="absolute right-1 top-1 rounded bg-black/70 px-1 text-[9px] text-white">
|
<span className="absolute right-1 top-1 rounded-sm bg-black/70 px-1 text-[9px] text-white">
|
||||||
✓
|
✓
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1110,7 +1110,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
|
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<div
|
<div
|
||||||
className={`absolute top-0 inset-x-0 p-4 bg-gradient-to-b from-black/70 to-transparent transition-opacity ${
|
className={`absolute top-0 inset-x-0 p-4 bg-linear-to-b from-black/70 to-transparent transition-opacity ${
|
||||||
uiVisible ? "opacity-100" : "opacity-0"
|
uiVisible ? "opacity-100" : "opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -1184,7 +1184,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
|
|
||||||
{/* Bottom controls */}
|
{/* Bottom controls */}
|
||||||
<div
|
<div
|
||||||
className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-8 bg-gradient-to-t from-black/80 to-transparent transition-opacity ${
|
className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-8 bg-linear-to-t from-black/80 to-transparent transition-opacity ${
|
||||||
uiVisible ? "opacity-100" : "opacity-0 pointer-events-none"
|
uiVisible ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -1221,7 +1221,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
{/* Hover timestamp tooltip — follows the cursor along the bar. */}
|
{/* Hover timestamp tooltip — follows the cursor along the bar. */}
|
||||||
{hover && duration > 0 && (
|
{hover && duration > 0 && (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded bg-black/90 px-1.5 py-0.5 text-[11px] tabular-nums text-white"
|
className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-[11px] tabular-nums text-white"
|
||||||
style={{ left: `${hover.frac * 100}%` }}
|
style={{ left: `${hover.frac * 100}%` }}
|
||||||
>
|
>
|
||||||
{fmt(hover.t)}
|
{fmt(hover.t)}
|
||||||
@@ -1311,13 +1311,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
/>
|
/>
|
||||||
{/* thumb */}
|
{/* thumb */}
|
||||||
<div
|
<div
|
||||||
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-accent shadow"
|
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-accent shadow-sm"
|
||||||
style={{ left: `${(muted ? 0 : volume) * 100}%` }}
|
style={{ left: `${(muted ? 0 : volume) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
{/* hover value tooltip (0–100), like the seek bar */}
|
{/* hover value tooltip (0–100), like the seek bar */}
|
||||||
{volHover != null && (
|
{volHover != null && (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded bg-black/90 px-1.5 py-0.5 text-[11px] tabular-nums text-white"
|
className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-[11px] tabular-nums text-white"
|
||||||
style={{ left: `${volHover * 100}%` }}
|
style={{ left: `${volHover * 100}%` }}
|
||||||
>
|
>
|
||||||
{Math.round(volHover * 100)}
|
{Math.round(volHover * 100)}
|
||||||
@@ -1544,7 +1544,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
type="color"
|
type="color"
|
||||||
value={prefs.subColor}
|
value={prefs.subColor}
|
||||||
onChange={(e) => patchPrefs({ subColor: e.target.value })}
|
onChange={(e) => patchPrefs({ subColor: e.target.value })}
|
||||||
className="h-6 w-10 cursor-pointer rounded bg-transparent"
|
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between px-1 py-1.5">
|
<div className="flex items-center justify-between px-1 py-1.5">
|
||||||
@@ -1570,7 +1570,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
type="color"
|
type="color"
|
||||||
value={prefs.subBg}
|
value={prefs.subBg}
|
||||||
onChange={(e) => patchPrefs({ subBg: e.target.value })}
|
onChange={(e) => patchPrefs({ subBg: e.target.value })}
|
||||||
className="h-6 w-10 cursor-pointer rounded bg-transparent"
|
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1593,7 +1593,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
type="color"
|
type="color"
|
||||||
value={prefs.subShadowColor}
|
value={prefs.subShadowColor}
|
||||||
onChange={(e) => patchPrefs({ subShadowColor: e.target.value })}
|
onChange={(e) => patchPrefs({ subShadowColor: e.target.value })}
|
||||||
className="h-6 w-10 cursor-pointer rounded bg-transparent"
|
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1629,7 +1629,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
type="color"
|
type="color"
|
||||||
value={prefs.clockColor}
|
value={prefs.clockColor}
|
||||||
onChange={(e) => patchPrefs({ clockColor: e.target.value })}
|
onChange={(e) => patchPrefs({ clockColor: e.target.value })}
|
||||||
className="h-6 w-10 cursor-pointer rounded bg-transparent"
|
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle
|
||||||
@@ -1728,7 +1728,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
{keys.map((k) => (
|
{keys.map((k) => (
|
||||||
<kbd
|
<kbd
|
||||||
key={k}
|
key={k}
|
||||||
className="rounded bg-white/15 px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
|
className="rounded-sm bg-white/15 px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{k}
|
{k}
|
||||||
</kbd>
|
</kbd>
|
||||||
@@ -1879,7 +1879,7 @@ function Ctrl({
|
|||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
className={`pointer-events-none absolute bottom-full ${pos} mb-2 whitespace-nowrap rounded bg-black/90 px-2 py-1 text-[11px] text-white opacity-0 transition group-hover/ctrl:opacity-100`}
|
className={`pointer-events-none absolute bottom-full ${pos} mb-2 whitespace-nowrap rounded-sm bg-black/90 px-2 py-1 text-[11px] text-white opacity-0 transition group-hover/ctrl:opacity-100`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export default function PlexPlaylistAdd({
|
|||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && createNew()}
|
onKeyDown={(e) => e.key === "Enter" && createNew()}
|
||||||
placeholder={t("plex.playlistAdd.newPlaceholder")}
|
placeholder={t("plex.playlistAdd.newPlaceholder")}
|
||||||
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm outline-none focus:border-accent"
|
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={createNew}
|
onClick={createNew}
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ type Ctx = {
|
|||||||
function Poster({ src, className }: { src: string; className: string }) {
|
function Poster({ src, className }: { src: string; className: string }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative shrink-0 overflow-hidden rounded border border-border bg-surface ${className}`}
|
className={`relative shrink-0 overflow-hidden rounded-sm border border-border bg-surface ${className}`}
|
||||||
>
|
>
|
||||||
<img src={src} alt="" loading="lazy" className="h-full w-full object-cover" />
|
<img src={src} alt="" loading="lazy" className="h-full w-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
@@ -161,11 +161,11 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
|
|||||||
className="group flex min-w-0 flex-1 items-center gap-2.5 text-left"
|
className="group flex min-w-0 flex-1 items-center gap-2.5 text-left"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`relative shrink-0 overflow-hidden rounded border border-border bg-surface ${dense ? "aspect-video w-14" : "aspect-video w-20"}`}
|
className={`relative shrink-0 overflow-hidden rounded-sm border border-border bg-surface ${dense ? "aspect-video w-14" : "aspect-video w-20"}`}
|
||||||
>
|
>
|
||||||
<img src={ep.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
|
<img src={ep.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
|
||||||
{ep.status === "watched" && (
|
{ep.status === "watched" && (
|
||||||
<span className="absolute right-0.5 top-0.5 rounded bg-black/70 px-1 text-[9px] text-white">
|
<span className="absolute right-0.5 top-0.5 rounded-sm bg-black/70 px-1 text-[9px] text-white">
|
||||||
✓
|
✓
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -179,7 +179,7 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
|
|||||||
<button
|
<button
|
||||||
onClick={() => ctx.onRemove([ep.id])}
|
onClick={() => ctx.onRemove([ep.id])}
|
||||||
disabled={ctx.busy}
|
disabled={ctx.busy}
|
||||||
className="shrink-0 rounded p-1 text-muted hover:text-red-400 disabled:opacity-30"
|
className="shrink-0 rounded-sm p-1 text-muted hover:text-red-400 disabled:opacity-30"
|
||||||
aria-label="remove"
|
aria-label="remove"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
@@ -210,7 +210,7 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
|
|||||||
onClick={() => ctx.onPlay(item.id)}
|
onClick={() => ctx.onPlay(item.id)}
|
||||||
className="group flex min-w-0 flex-1 items-center gap-3 text-left"
|
className="group flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||||
>
|
>
|
||||||
<Poster src={item.thumb} className={dense ? "aspect-[2/3] w-10" : "aspect-[2/3] w-14"} />
|
<Poster src={item.thumb} className={dense ? "aspect-2/3 w-10" : "aspect-2/3 w-14"} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-medium">{item.title}</div>
|
<div className="truncate text-sm font-medium">{item.title}</div>
|
||||||
<div className="text-xs text-muted">{item.year}</div>
|
<div className="text-xs text-muted">{item.year}</div>
|
||||||
@@ -222,7 +222,7 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => ctx.onRemove([item.id])}
|
onClick={() => ctx.onRemove([item.id])}
|
||||||
disabled={ctx.busy}
|
disabled={ctx.busy}
|
||||||
className="shrink-0 rounded p-1 text-muted hover:text-red-400 disabled:opacity-30"
|
className="shrink-0 rounded-sm p-1 text-muted hover:text-red-400 disabled:opacity-30"
|
||||||
aria-label="remove"
|
aria-label="remove"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
@@ -256,7 +256,7 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
|
|||||||
<DragHandle {...attributes} {...listeners} />
|
<DragHandle {...attributes} {...listeners} />
|
||||||
<button
|
<button
|
||||||
onClick={() => ctx.toggleShow(block.showKey)}
|
onClick={() => ctx.toggleShow(block.showKey)}
|
||||||
className="grid h-6 w-6 shrink-0 place-items-center rounded text-muted hover:text-fg"
|
className="grid h-6 w-6 shrink-0 place-items-center rounded-sm text-muted hover:text-fg"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
>
|
>
|
||||||
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
@@ -269,7 +269,7 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
|
|||||||
src={ep.thumb}
|
src={ep.thumb}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="aspect-[2/3] w-8 rounded border-2 border-surface object-cover"
|
className="aspect-2/3 w-8 rounded-sm border-2 border-surface object-cover"
|
||||||
style={{ marginLeft: i ? -14 : 0 }}
|
style={{ marginLeft: i ? -14 : 0 }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -285,7 +285,7 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
|
|||||||
onClick={() => ctx.onRemove(block.items.map((i) => i.id))}
|
onClick={() => ctx.onRemove(block.items.map((i) => i.id))}
|
||||||
disabled={ctx.busy}
|
disabled={ctx.busy}
|
||||||
title={t("plex.playlist.removeShow")}
|
title={t("plex.playlist.removeShow")}
|
||||||
className="shrink-0 rounded p-1 text-muted hover:text-red-400 disabled:opacity-30"
|
className="shrink-0 rounded-sm p-1 text-muted hover:text-red-400 disabled:opacity-30"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -306,7 +306,7 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
|
|||||||
{dense && (
|
{dense && (
|
||||||
<button
|
<button
|
||||||
onClick={() => ctx.toggleSeason(sKey)}
|
onClick={() => ctx.toggleSeason(sKey)}
|
||||||
className="grid h-5 w-5 shrink-0 place-items-center rounded text-muted hover:text-fg"
|
className="grid h-5 w-5 shrink-0 place-items-center rounded-sm text-muted hover:text-fg"
|
||||||
aria-expanded={sOpen}
|
aria-expanded={sOpen}
|
||||||
>
|
>
|
||||||
{sOpen ? (
|
{sOpen ? (
|
||||||
@@ -323,7 +323,7 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
|
|||||||
onClick={() => ctx.onRemove(s.items.map((i) => i.id))}
|
onClick={() => ctx.onRemove(s.items.map((i) => i.id))}
|
||||||
disabled={ctx.busy}
|
disabled={ctx.busy}
|
||||||
title={t("plex.playlist.removeSeason")}
|
title={t("plex.playlist.removeSeason")}
|
||||||
className="ml-1 rounded p-0.5 text-muted hover:text-red-400 disabled:opacity-30"
|
className="ml-1 rounded-sm p-0.5 text-muted hover:text-red-400 disabled:opacity-30"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -532,7 +532,7 @@ export default function PlexPlaylistView({
|
|||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && rename()}
|
onKeyDown={(e) => e.key === "Enter" && rename()}
|
||||||
onBlur={rename}
|
onBlur={rename}
|
||||||
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-1.5 text-lg font-bold outline-none focus:border-accent"
|
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-1.5 text-lg font-bold outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<h1 className="min-w-0 flex-1 truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
|
<h1 className="min-w-0 flex-1 truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
onChange={(e) => setNewPlaylist(e.target.value)}
|
onChange={(e) => setNewPlaylist(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && createPlaylist()}
|
onKeyDown={(e) => e.key === "Enter" && createPlaylist()}
|
||||||
placeholder={t("plex.playlist.newPlaceholder")}
|
placeholder={t("plex.playlist.newPlaceholder")}
|
||||||
className="glass-card min-w-0 flex-1 rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
|
className="glass-card min-w-0 flex-1 rounded-lg px-2 py-1 text-xs outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={createPlaylist}
|
onClick={createPlaylist}
|
||||||
@@ -190,7 +190,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
onClick={() => onOpenPlaylist(p.id)}
|
onClick={() => onOpenPlaylist(p.id)}
|
||||||
className="glass-hover flex w-full items-center gap-2 rounded px-1.5 py-1 text-left text-sm"
|
className="glass-hover flex w-full items-center gap-2 rounded-sm px-1.5 py-1 text-left text-sm"
|
||||||
>
|
>
|
||||||
<ListMusic className="h-3.5 w-3.5 shrink-0 text-muted" />
|
<ListMusic className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||||
<span className="min-w-0 flex-1 truncate">{p.title}</span>
|
<span className="min-w-0 flex-1 truncate">{p.title}</span>
|
||||||
@@ -298,7 +298,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
value={collSearch}
|
value={collSearch}
|
||||||
onChange={(e) => setCollSearch(e.target.value)}
|
onChange={(e) => setCollSearch(e.target.value)}
|
||||||
placeholder={t("plex.filter.collectionSearch")}
|
placeholder={t("plex.filter.collectionSearch")}
|
||||||
className="mb-1.5 w-full rounded-lg border border-border bg-card px-2 py-1 text-sm focus:border-accent focus:outline-none"
|
className="mb-1.5 w-full rounded-lg border border-border bg-card px-2 py-1 text-sm focus:border-accent focus:outline-hidden"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
ref={collFade.ref}
|
ref={collFade.ref}
|
||||||
@@ -519,7 +519,7 @@ function Chip({
|
|||||||
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
|
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent/15 text-accent px-2 py-1 text-xs">
|
<span className="inline-flex items-center gap-1 rounded-full bg-accent/15 text-accent px-2 py-1 text-xs">
|
||||||
<span className="truncate max-w-[9rem]">{label}</span>
|
<span className="truncate max-w-36">{label}</span>
|
||||||
<button onClick={onRemove} className="hover:opacity-70" aria-label="remove">
|
<button onClick={onRemove} className="hover:opacity-70" aria-label="remove">
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -546,7 +546,7 @@ function NumberInput({
|
|||||||
const n = e.target.value === "" ? null : Number(e.target.value);
|
const n = e.target.value === "" ? null : Number(e.target.value);
|
||||||
onChange(n != null && Number.isFinite(n) ? n : null);
|
onChange(n != null && Number.isFinite(n) ? n : null);
|
||||||
}}
|
}}
|
||||||
className="w-20 rounded-lg border border-border bg-card px-2 py-1 text-sm tabular-nums focus:border-accent focus:outline-none"
|
className="w-20 rounded-lg border border-border bg-card px-2 py-1 text-sm tabular-nums focus:border-accent focus:outline-hidden"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,14 +129,14 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => startEdit(p)}
|
onClick={() => startEdit(p)}
|
||||||
title={t("downloads.actions.rename")}
|
title={t("downloads.actions.rename")}
|
||||||
className="p-1 rounded hover:bg-surface text-muted hover:text-fg"
|
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-fg"
|
||||||
>
|
>
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onDelete(p)}
|
onClick={() => onDelete(p)}
|
||||||
title={t("downloads.profiles.delete")}
|
title={t("downloads.profiles.delete")}
|
||||||
className="p-1 rounded hover:bg-surface text-muted hover:text-red-400"
|
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-red-400"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function ReleaseNotes({
|
|||||||
<h3 className="text-base font-semibold">v{r.version}</h3>
|
<h3 className="text-base font-semibold">v{r.version}</h3>
|
||||||
<span className="text-xs text-muted">{fmtDate(r.date)}</span>
|
<span className="text-xs text-muted">{fmtDate(r.date)}</span>
|
||||||
{r.sha && (
|
{r.sha && (
|
||||||
<span className="text-[11px] font-mono text-muted bg-surface px-1.5 py-0.5 rounded">
|
<span className="text-[11px] font-mono text-muted bg-surface px-1.5 py-0.5 rounded-sm">
|
||||||
{r.sha}
|
{r.sha}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export default function SavedViewsWidget({
|
|||||||
}}
|
}}
|
||||||
placeholder={t("views.namePlaceholder")}
|
placeholder={t("views.namePlaceholder")}
|
||||||
maxLength={80}
|
maxLength={80}
|
||||||
className="flex-1 min-w-0 bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
|
className="flex-1 min-w-0 bg-card border border-border rounded-lg px-2 py-1 text-xs outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => void save()}
|
onClick={() => void save()}
|
||||||
@@ -256,7 +256,7 @@ function ViewRow({
|
|||||||
if (e.key === "Escape") onCancelRename();
|
if (e.key === "Escape") onCancelRename();
|
||||||
}}
|
}}
|
||||||
maxLength={80}
|
maxLength={80}
|
||||||
className="flex-1 min-w-0 bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
|
className="flex-1 min-w-0 bg-card border border-border rounded-lg px-2 py-1 text-xs outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => onRename(draft)}
|
onClick={() => onRename(draft)}
|
||||||
@@ -316,21 +316,21 @@ function ViewRow({
|
|||||||
<button
|
<button
|
||||||
onClick={onShare}
|
onClick={onShare}
|
||||||
title={t("views.share")}
|
title={t("views.share")}
|
||||||
className="p-1 rounded text-muted opacity-0 group-hover:opacity-100 hover:text-fg transition"
|
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 hover:text-fg transition"
|
||||||
>
|
>
|
||||||
<Share2 className="w-3.5 h-3.5" />
|
<Share2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onStartRename}
|
onClick={onStartRename}
|
||||||
title={t("views.rename")}
|
title={t("views.rename")}
|
||||||
className="p-1 rounded text-muted opacity-0 group-hover:opacity-100 hover:text-fg transition"
|
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 hover:text-fg transition"
|
||||||
>
|
>
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
title={t("views.delete")}
|
title={t("views.delete")}
|
||||||
className="p-1 rounded text-muted opacity-0 group-hover:opacity-100 hover:text-red-400 transition"
|
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 hover:text-red-400 transition"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ function JobRow({
|
|||||||
if (e.key === "Enter") save();
|
if (e.key === "Enter") save();
|
||||||
if (e.key === "Escape") setEditing(false);
|
if (e.key === "Escape") setEditing(false);
|
||||||
}}
|
}}
|
||||||
className="w-16 bg-card border border-border rounded px-1.5 py-0.5 text-xs outline-none focus:border-accent"
|
className="w-16 bg-card border border-border rounded-sm px-1.5 py-0.5 text-xs outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted text-xs">{t("scheduler.minutes")}</span>
|
<span className="text-muted text-xs">{t("scheduler.minutes")}</span>
|
||||||
<button
|
<button
|
||||||
@@ -281,7 +281,7 @@ function MaintenanceSettings({
|
|||||||
onChange={(e) => setVal(e.target.value)}
|
onChange={(e) => setVal(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && save()}
|
onKeyDown={(e) => e.key === "Enter" && save()}
|
||||||
aria-label={t("scheduler.maintenance.batchLabel")}
|
aria-label={t("scheduler.maintenance.batchLabel")}
|
||||||
className="w-28 bg-card border border-border rounded px-2 py-1 text-sm tabular-nums outline-none focus:border-accent"
|
className="w-28 bg-card border border-border rounded-sm px-2 py-1 text-sm tabular-nums outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={save}
|
onClick={save}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function SearchBar({
|
|||||||
if (e.key === "Enter") fire();
|
if (e.key === "Enter") fire();
|
||||||
}}
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="flex-1 min-w-0 bg-transparent outline-none text-sm placeholder:text-muted"
|
className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
|
||||||
/>
|
/>
|
||||||
{value && (
|
{value && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ function SignInMethods({ me }: { me: Me }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const inputCls =
|
const inputCls =
|
||||||
"w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent";
|
"w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-hidden focus:border-accent";
|
||||||
|
|
||||||
// Offer Google only when this instance has Google OAuth configured (or the account is already
|
// Offer Google only when this instance has Google OAuth configured (or the account is already
|
||||||
// linked — then we still show its "Connected" status even if the instance creds were removed).
|
// linked — then we still show its "Connected" status even if the instance creds were removed).
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export default function SetupWizard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inputCls =
|
const inputCls =
|
||||||
"w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent";
|
"w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-hidden focus:border-accent";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Centered>
|
<Centered>
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
|||||||
<input
|
<input
|
||||||
readOnly
|
readOnly
|
||||||
value={fullUrl}
|
value={fullUrl}
|
||||||
className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-none"
|
className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-hidden"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={copy}
|
onClick={copy}
|
||||||
@@ -184,7 +184,10 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
|||||||
<Eye className="w-3 h-3" /> {t("downloads.share.views", { n: link.view_count })}
|
<Eye className="w-3 h-3" /> {t("downloads.share.views", { n: link.view_count })}
|
||||||
</span>
|
</span>
|
||||||
{badges.map((b) => (
|
{badges.map((b) => (
|
||||||
<span key={b} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-surface">
|
<span
|
||||||
|
key={b}
|
||||||
|
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-sm bg-surface"
|
||||||
|
>
|
||||||
{b === t("downloads.share.hasPassword") && <Lock className="w-3 h-3" />}
|
{b === t("downloads.share.hasPassword") && <Lock className="w-3 h-3" />}
|
||||||
{b}
|
{b}
|
||||||
</span>
|
</span>
|
||||||
@@ -208,7 +211,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
|||||||
placeholder={t(
|
placeholder={t(
|
||||||
link.has_password ? "downloads.share.newPassword" : "downloads.share.setPassword",
|
link.has_password ? "downloads.share.newPassword" : "downloads.share.setPassword",
|
||||||
)}
|
)}
|
||||||
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => savePass.mutate(newPass.trim())}
|
onClick={() => savePass.mutate(newPass.trim())}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function TagChip({
|
|||||||
data-tag={tag.name}
|
data-tag={tag.name}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
title={t("sidebar.channelCount", { count })}
|
title={t("sidebar.channelCount", { count })}
|
||||||
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-sm hover:shadow active:translate-y-px transition ${
|
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-xs hover:shadow-sm active:translate-y-px transition ${
|
||||||
active
|
active
|
||||||
? "bg-accent text-accent-fg border-accent"
|
? "bg-accent text-accent-fg border-accent"
|
||||||
: "bg-card border-border text-fg hover:border-accent"
|
: "bg-card border-border text-fg hover:border-accent"
|
||||||
@@ -169,7 +169,7 @@ export default function Sidebar({
|
|||||||
dateTo: undefined,
|
dateTo: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm active:translate-y-px transition ${
|
className={`text-xs px-2.5 py-1 rounded-full border shadow-xs active:translate-y-px transition ${
|
||||||
activePreset
|
activePreset
|
||||||
? "bg-accent text-accent-fg border-accent"
|
? "bg-accent text-accent-fg border-accent"
|
||||||
: "bg-card border-border hover:border-accent"
|
: "bg-card border-border hover:border-accent"
|
||||||
@@ -185,7 +185,7 @@ export default function Sidebar({
|
|||||||
setCustomDates(next);
|
setCustomDates(next);
|
||||||
if (next) setFilters({ ...filters, maxAgeDays: undefined });
|
if (next) setFilters({ ...filters, maxAgeDays: undefined });
|
||||||
}}
|
}}
|
||||||
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm active:translate-y-px transition ${
|
className={`text-xs px-2.5 py-1 rounded-full border shadow-xs active:translate-y-px transition ${
|
||||||
customDates || filters.dateFrom || filters.dateTo
|
customDates || filters.dateFrom || filters.dateTo
|
||||||
? "bg-accent text-accent-fg border-accent"
|
? "bg-accent text-accent-fg border-accent"
|
||||||
: "bg-card border-border hover:border-accent"
|
: "bg-card border-border hover:border-accent"
|
||||||
@@ -209,7 +209,7 @@ export default function Sidebar({
|
|||||||
maxAgeDays: undefined,
|
maxAgeDays: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center justify-between gap-2 text-xs">
|
<label className="flex items-center justify-between gap-2 text-xs">
|
||||||
@@ -224,7 +224,7 @@ export default function Sidebar({
|
|||||||
maxAgeDays: undefined,
|
maxAgeDays: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{(filters.dateFrom || filters.dateTo) && (
|
{(filters.dateFrom || filters.dateTo) && (
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ function TagRow({
|
|||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
|
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
|
||||||
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1.5 text-sm outline-none focus:border-accent"
|
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1.5 text-sm outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
ref={pop.refs.setReference}
|
ref={pop.refs.setReference}
|
||||||
@@ -160,7 +160,7 @@ export default function TagManager({
|
|||||||
<div
|
<div
|
||||||
ref={listFade.ref}
|
ref={listFade.ref}
|
||||||
style={listFade.style}
|
style={listFade.style}
|
||||||
className="flex flex-col gap-2 max-h-[22rem] overflow-y-auto no-scrollbar pr-1"
|
className="flex flex-col gap-2 max-h-88 overflow-y-auto no-scrollbar pr-1"
|
||||||
>
|
>
|
||||||
{userTags.map((tag) => (
|
{userTags.map((tag) => (
|
||||||
<TagRow
|
<TagRow
|
||||||
@@ -201,7 +201,7 @@ export default function TagManager({
|
|||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
placeholder={t("tagManager.newPlaceholder")}
|
placeholder={t("tagManager.newPlaceholder")}
|
||||||
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1.5 text-sm outline-none focus:border-accent"
|
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1.5 text-sm outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function Toaster() {
|
|||||||
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>}
|
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>}
|
||||||
<div className="text-sm break-words">
|
<div className="text-sm wrap-break-word">
|
||||||
{toast.message}
|
{toast.message}
|
||||||
{toast.repeat > 1 && (
|
{toast.repeat > 1 && (
|
||||||
<span className="ml-1.5 align-middle text-[11px] font-semibold text-muted tabular-nums">
|
<span className="ml-1.5 align-middle text-[11px] font-semibold text-muted tabular-nums">
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ function Thumb({
|
|||||||
<div className="w-full h-full" />
|
<div className="w-full h-full" />
|
||||||
)}
|
)}
|
||||||
{video.duration_seconds != null ? (
|
{video.duration_seconds != null ? (
|
||||||
<span className="absolute bottom-1.5 right-1.5 bg-black/80 text-white text-[11px] font-medium px-1.5 py-0.5 rounded">
|
<span className="absolute bottom-1.5 right-1.5 bg-black/80 text-white text-[11px] font-medium px-1.5 py-0.5 rounded-sm">
|
||||||
{formatDuration(video.duration_seconds)}
|
{formatDuration(video.duration_seconds)}
|
||||||
</span>
|
</span>
|
||||||
) : video.live_status === "live" || video.live_status === "upcoming" ? (
|
) : video.live_status === "live" || video.live_status === "upcoming" ? (
|
||||||
@@ -257,7 +257,7 @@ function Thumb({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{video.live_status === "was_live" && (
|
{video.live_status === "was_live" && (
|
||||||
<span className="absolute top-1.5 left-1.5 bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
|
<span className="absolute top-1.5 left-1.5 bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
|
||||||
{t("card.stream")}
|
{t("card.stream")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -288,7 +288,7 @@ function Thumb({
|
|||||||
title={t("card.restartTitle")}
|
title={t("card.restartTitle")}
|
||||||
aria-label={t("card.restart")}
|
aria-label={t("card.restart")}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"inline-flex items-center justify-center gap-1.5 font-medium bg-white/15 text-white backdrop-blur-sm hover:bg-white/25 transition",
|
"inline-flex items-center justify-center gap-1.5 font-medium bg-white/15 text-white backdrop-blur-xs hover:bg-white/25 transition",
|
||||||
small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 rounded-lg text-sm",
|
small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 rounded-lg text-sm",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -307,7 +307,7 @@ function Thumb({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Play
|
<Play
|
||||||
className={clsx("fill-current translate-x-[1px]", small ? "w-4 h-4" : "w-5 h-5")}
|
className={clsx("fill-current translate-x-px", small ? "w-4 h-4" : "w-5 h-5")}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ type Frac = { x: number; y: number; w: number; h: number };
|
|||||||
type Drag = { type: "seek" } | { type: "boundary"; bi: number };
|
type Drag = { type: "seek" } | { type: "boundary"; bi: number };
|
||||||
|
|
||||||
const numCls =
|
const numCls =
|
||||||
"w-24 rounded-md bg-surface border border-border px-2 py-1 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-accent/40";
|
"w-24 rounded-md bg-surface border border-border px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 focus:ring-accent/40";
|
||||||
const MIN_SEG = 0.1;
|
const MIN_SEG = 0.1;
|
||||||
|
|
||||||
export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||||
@@ -348,7 +348,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
onPointerDown={(e) => onCropDown(e, "move")}
|
onPointerDown={(e) => onCropDown(e, "move")}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute -right-1.5 -bottom-1.5 w-4 h-4 rounded-sm bg-accent cursor-nwse-resize"
|
className="absolute -right-1.5 -bottom-1.5 w-4 h-4 rounded-xs bg-accent cursor-nwse-resize"
|
||||||
onPointerDown={(e) => onCropDown(e, "resize")}
|
onPointerDown={(e) => onCropDown(e, "resize")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -432,7 +432,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
toggleKeep(s.id);
|
toggleKeep(s.id);
|
||||||
}}
|
}}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"absolute top-1 left-1 p-0.5 rounded bg-black/50 backdrop-blur-sm",
|
"absolute top-1 left-1 p-0.5 rounded-sm bg-black/50 backdrop-blur-xs",
|
||||||
s.keep ? "text-accent" : "text-muted",
|
s.keep ? "text-accent" : "text-muted",
|
||||||
)}
|
)}
|
||||||
title={s.keep ? t("editor.drop") : t("editor.keep")}
|
title={s.keep ? t("editor.drop") : t("editor.keep")}
|
||||||
@@ -449,7 +449,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
style={{ left: pct(b) }}
|
style={{ left: pct(b) }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-1 h-full bg-white/90 cursor-ew-resize rounded"
|
className="w-1 h-full bg-white/90 cursor-ew-resize rounded-sm"
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDrag({ type: "boundary", bi });
|
setDrag({ type: "boundary", bi });
|
||||||
@@ -460,7 +460,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
deleteBoundary(bi);
|
deleteBoundary(bi);
|
||||||
}}
|
}}
|
||||||
className="absolute -top-0 -right-2 p-0.5 rounded-full bg-black/70 text-muted hover:text-red-400"
|
className="absolute top-0 -right-2 p-0.5 rounded-full bg-black/70 text-muted hover:text-red-400"
|
||||||
title={t("editor.removeCut")}
|
title={t("editor.removeCut")}
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export default function ViewSwitcher<T extends string>({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
label={o.label}
|
label={o.label}
|
||||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left outline-none transition data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] ${
|
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left outline-hidden transition data-active:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] ${
|
||||||
on ? "text-accent" : "text-fg hover:bg-card"
|
on ? "text-accent" : "text-fg hover:bg-card"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export default function WatchPage() {
|
|||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && password && unlock()}
|
onKeyDown={(e) => e.key === "Enter" && password && unlock()}
|
||||||
placeholder={T.passwordPlaceholder}
|
placeholder={T.passwordPlaceholder}
|
||||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-accent/40"
|
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||||
/>
|
/>
|
||||||
{error && <div className="text-sm text-red-400 mt-2">{error}</div>}
|
{error && <div className="text-sm text-red-400 mt-2">{error}</div>}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function Welcome() {
|
|||||||
href={REPO_URL}
|
href={REPO_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="group mt-8 flex flex-col items-center gap-x-4 gap-y-2 rounded-2xl border border-accent/25 bg-accent/[0.06] px-5 py-4 text-center sm:flex-row sm:text-left hover:border-accent/50 transition"
|
className="group mt-8 flex flex-col items-center gap-x-4 gap-y-2 rounded-2xl border border-accent/25 bg-accent/6 px-5 py-4 text-center sm:flex-row sm:text-left hover:border-accent/50 transition"
|
||||||
>
|
>
|
||||||
<span className="grid place-items-center w-10 h-10 rounded-xl bg-accent/15 text-accent shrink-0">
|
<span className="grid place-items-center w-10 h-10 rounded-xl bg-accent/15 text-accent shrink-0">
|
||||||
<Code2 className="w-5 h-5" />
|
<Code2 className="w-5 h-5" />
|
||||||
@@ -213,7 +213,7 @@ function Preview({
|
|||||||
if (failed) {
|
if (failed) {
|
||||||
return (
|
return (
|
||||||
<div className={`glass-card rounded-2xl overflow-hidden ${className ?? ""}`}>
|
<div className={`glass-card rounded-2xl overflow-hidden ${className ?? ""}`}>
|
||||||
<div className="w-full h-full grid place-items-center bg-gradient-to-br from-accent/10 to-card text-muted text-sm p-6 text-center">
|
<div className="w-full h-full grid place-items-center bg-linear-to-br from-accent/10 to-card text-muted text-sm p-6 text-center">
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -258,7 +258,7 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
|
|||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-popover grid place-items-center bg-black/80 backdrop-blur-sm p-4 sm:p-8"
|
className="fixed inset-0 z-popover grid place-items-center bg-black/80 backdrop-blur-xs p-4 sm:p-8"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@@ -431,7 +431,7 @@ function AuthCard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inputCls =
|
const inputCls =
|
||||||
"w-full bg-card border border-border rounded-xl px-3 py-2.5 text-sm outline-none focus:border-accent";
|
"w-full bg-card border border-border rounded-xl px-3 py-2.5 text-sm outline-hidden focus:border-accent";
|
||||||
const titleKey = TITLE_KEY[mode];
|
const titleKey = TITLE_KEY[mode];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -81,12 +81,12 @@ export function auditColumns(
|
|||||||
render: (r) => {
|
render: (r) => {
|
||||||
const change = changeText(r);
|
const change = changeText(r);
|
||||||
return (
|
return (
|
||||||
// break-words (wrap), NOT truncate: in the auto-layout table a `truncate` (nowrap) cell
|
// wrap-break-word (wrap), NOT truncate: in the auto-layout table a `truncate` (nowrap) cell
|
||||||
// sizes to its longest row, so one long summary/change stretched the whole table past the
|
// sizes to its longest row, so one long summary/change stretched the whole table past the
|
||||||
// card (the row rules bled into the page). Wrapping keeps every row inside the container.
|
// card (the row rules bled into the page). Wrapping keeps every row inside the container.
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{r.summary && <div className="break-words">{r.summary}</div>}
|
{r.summary && <div className="wrap-break-word">{r.summary}</div>}
|
||||||
{change && <div className="text-[11px] text-muted break-words">{change}</div>}
|
{change && <div className="text-[11px] text-muted wrap-break-word">{change}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function playlistColumns({
|
|||||||
<button
|
<button
|
||||||
onClick={() => onPlay(v)}
|
onClick={() => onPlay(v)}
|
||||||
aria-label={`${t("card.play")} — ${v.title}`}
|
aria-label={`${t("card.play")} — ${v.title}`}
|
||||||
className="relative w-[62px] h-[35px] rounded overflow-hidden bg-surface shrink-0 group"
|
className="relative w-[62px] h-[35px] rounded-sm overflow-hidden bg-surface shrink-0 group"
|
||||||
>
|
>
|
||||||
{v.thumbnail_url && (
|
{v.thumbnail_url && (
|
||||||
<img src={v.thumbnail_url} alt="" className="w-full h-full object-cover" />
|
<img src={v.thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ function FilterFunnel<T>({
|
|||||||
value={(value as string) ?? ""}
|
value={(value as string) ?? ""}
|
||||||
onChange={(e) => onApply(col.key, e.target.value)}
|
onChange={(e) => onApply(col.key, e.target.value)}
|
||||||
placeholder={col.header}
|
placeholder={col.header}
|
||||||
className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{f.kind === "select" && (
|
{f.kind === "select" && (
|
||||||
@@ -158,7 +158,7 @@ function FilterFunnel<T>({
|
|||||||
checked={!filterOn}
|
checked={!filterOn}
|
||||||
onSelect={() => onApply(col.key, "")}
|
onSelect={() => onApply(col.key, "")}
|
||||||
label={t("datatable.all")}
|
label={t("datatable.all")}
|
||||||
className={`text-left text-xs px-2 py-1.5 rounded-md outline-none transition data-[active]:ring-1 data-[active]:ring-accent ${
|
className={`text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition data-active:ring-1 data-active:ring-accent ${
|
||||||
!filterOn ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
!filterOn ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -171,7 +171,7 @@ function FilterFunnel<T>({
|
|||||||
checked={value === o.value}
|
checked={value === o.value}
|
||||||
onSelect={() => onApply(col.key, o.value)}
|
onSelect={() => onApply(col.key, o.value)}
|
||||||
label={o.label}
|
label={o.label}
|
||||||
className={`text-left text-xs px-2 py-1.5 rounded-md outline-none transition data-[active]:ring-1 data-[active]:ring-accent ${
|
className={`text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition data-active:ring-1 data-active:ring-accent ${
|
||||||
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -197,7 +197,7 @@ function FilterFunnel<T>({
|
|||||||
onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value])
|
onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value])
|
||||||
}
|
}
|
||||||
label={o.label}
|
label={o.label}
|
||||||
className={`flex w-full items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-none transition data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] ${
|
className={`flex w-full items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition data-active:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] ${
|
||||||
on ? "text-fg" : "text-muted hover:bg-card"
|
on ? "text-fg" : "text-muted hover:bg-card"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import Tooltip from "../Tooltip";
|
|||||||
* `bg-card rounded-xl focus:border-accent` style; unifying the two is a design call left to the
|
* `bg-card rounded-xl focus:border-accent` style; unifying the two is a design call left to the
|
||||||
* glass-consistency epic, not this DRY extract. */
|
* glass-consistency epic, not this DRY extract. */
|
||||||
export const inputCls =
|
export const inputCls =
|
||||||
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40";
|
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40";
|
||||||
|
|
||||||
/** The standard text button style (used verbatim by ShareDialog + VideoEditor). */
|
/** The standard text button style (used verbatim by ShareDialog + VideoEditor). */
|
||||||
export const btnCls = "px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50";
|
export const btnCls = "px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50";
|
||||||
|
|||||||
+48
-3
@@ -1,6 +1,51 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
/* `inline` is load-bearing: the color vars (--bg/--accent/… below) are redefined at
|
||||||
|
runtime per html[data-scheme]/[data-theme], so utilities must inline the value and
|
||||||
|
resolve it at the USE site, not reference a once-computed --color-* token. This also
|
||||||
|
makes the v4 opacity modifier (bg-accent/40 → color-mix(… var(--accent) 40% …)) pick
|
||||||
|
up the active scheme's color. Same pattern as shadcn's v4 theme setup. */
|
||||||
|
@theme inline {
|
||||||
|
--color-bg: var(--bg);
|
||||||
|
--color-surface: var(--surface);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-fg: var(--fg);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-fg: var(--accent-fg);
|
||||||
|
|
||||||
|
--font-sans: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
|
||||||
|
--z-index-base: 10;
|
||||||
|
--z-index-menu: 20;
|
||||||
|
--z-index-chrome: 30;
|
||||||
|
--z-index-paneltab: 34;
|
||||||
|
--z-index-panel: 35;
|
||||||
|
--z-index-rail: 40;
|
||||||
|
--z-index-overlay: 50;
|
||||||
|
--z-index-popover: 60;
|
||||||
|
--z-index-tooltip: 100;
|
||||||
|
--z-index-tuner: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||||
|
so we've added these compatibility styles to make sure everything still
|
||||||
|
looks the same as it did with Tailwind CSS v3.
|
||||||
|
|
||||||
|
If we ever want to remove these styles, we need to add an explicit border
|
||||||
|
color utility to any element that depends on these defaults.
|
||||||
|
*/
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
::after,
|
||||||
|
::before,
|
||||||
|
::backdrop,
|
||||||
|
::file-selector-button {
|
||||||
|
border-color: var(--color-gray-200, currentcolor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-scale: 1.06;
|
--font-scale: 1.06;
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
bg: "var(--bg)",
|
|
||||||
surface: "var(--surface)",
|
|
||||||
card: "var(--card)",
|
|
||||||
border: "var(--border)",
|
|
||||||
fg: "var(--fg)",
|
|
||||||
muted: "var(--muted)",
|
|
||||||
accent: "var(--accent)",
|
|
||||||
"accent-fg": "var(--accent-fg)",
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
sans: ["Inter", "system-ui", "-apple-system", "Segoe UI", "Roboto", "sans-serif"],
|
|
||||||
},
|
|
||||||
// Named z-index layers — the single source of truth for stacking order. Values are
|
|
||||||
// deliberately UNCHANGED from the magic numbers they replaced (this was a rename, not a
|
|
||||||
// renumber), so the visual layering is identical; the names just make the stack legible and
|
|
||||||
// give future changes one place to edit. Low → high:
|
|
||||||
// base(10) in-content decorations: sticky table head, card badges, in-flow dropdowns
|
|
||||||
// menu(20) menus/overlays sitting just over content
|
|
||||||
// chrome(30) floating chrome: the header band, back-to-top, content filter/sort menus
|
|
||||||
// paneltab / panel (34/35) the collapsed tab vs the expanded side panel (aside over tab)
|
|
||||||
// rail(40) full-height rails + docked bars: nav rail, chat dock, floating save bar
|
|
||||||
// overlay(50) full-screen modals/players, toaster, onboarding, popovers over content
|
|
||||||
// popover(60) a popover that must sit over an overlay (player scrub, tag menu) + the login gate
|
|
||||||
// tooltip(100) tooltips, above everything interactive
|
|
||||||
// tuner(9999) the dev/lab glass tuner, deliberately on top of all
|
|
||||||
zIndex: {
|
|
||||||
base: "10",
|
|
||||||
menu: "20",
|
|
||||||
chrome: "30",
|
|
||||||
paneltab: "34",
|
|
||||||
panel: "35",
|
|
||||||
rail: "40",
|
|
||||||
overlay: "50",
|
|
||||||
popover: "60",
|
|
||||||
tooltip: "100",
|
|
||||||
tuner: "9999",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user