Merge improvement/playlist-rail-sort: rail sorting + persisted selection
This commit is contained in:
commit
c06989473e
6 changed files with 237 additions and 57 deletions
|
|
@ -31,7 +31,13 @@ def _mark_dirty(pl: Playlist) -> None:
|
||||||
pl.dirty = True
|
pl.dirty = True
|
||||||
|
|
||||||
|
|
||||||
def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = None) -> dict:
|
def _summary(
|
||||||
|
db: Session,
|
||||||
|
pl: Playlist,
|
||||||
|
count: int,
|
||||||
|
total_duration: int = 0,
|
||||||
|
has_video: bool | None = None,
|
||||||
|
) -> dict:
|
||||||
cover = db.scalar(
|
cover = db.scalar(
|
||||||
select(Video.thumbnail_url)
|
select(Video.thumbnail_url)
|
||||||
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
|
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
|
||||||
|
|
@ -47,6 +53,7 @@ def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = Non
|
||||||
"yt_playlist_id": pl.yt_playlist_id,
|
"yt_playlist_id": pl.yt_playlist_id,
|
||||||
"dirty": pl.dirty,
|
"dirty": pl.dirty,
|
||||||
"item_count": count,
|
"item_count": count,
|
||||||
|
"total_duration_seconds": total_duration,
|
||||||
"cover_thumbnail": cover,
|
"cover_thumbnail": cover,
|
||||||
}
|
}
|
||||||
if has_video is not None:
|
if has_video is not None:
|
||||||
|
|
@ -54,6 +61,18 @@ def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = Non
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _total_duration(db: Session, playlist_id: int) -> int:
|
||||||
|
return (
|
||||||
|
db.scalar(
|
||||||
|
select(func.coalesce(func.sum(Video.duration_seconds), 0))
|
||||||
|
.select_from(PlaylistItem)
|
||||||
|
.join(Video, Video.id == PlaylistItem.video_id)
|
||||||
|
.where(PlaylistItem.playlist_id == playlist_id)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_playlists(
|
def list_playlists(
|
||||||
contains: str | None = None,
|
contains: str | None = None,
|
||||||
|
|
@ -81,6 +100,17 @@ def list_playlists(
|
||||||
.group_by(PlaylistItem.playlist_id)
|
.group_by(PlaylistItem.playlist_id)
|
||||||
).all()
|
).all()
|
||||||
)
|
)
|
||||||
|
durations = dict(
|
||||||
|
db.execute(
|
||||||
|
select(
|
||||||
|
PlaylistItem.playlist_id,
|
||||||
|
func.coalesce(func.sum(Video.duration_seconds), 0),
|
||||||
|
)
|
||||||
|
.join(Video, Video.id == PlaylistItem.video_id)
|
||||||
|
.where(PlaylistItem.playlist_id.in_(ids))
|
||||||
|
.group_by(PlaylistItem.playlist_id)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
member: set[int] = set()
|
member: set[int] = set()
|
||||||
if contains:
|
if contains:
|
||||||
member = set(
|
member = set(
|
||||||
|
|
@ -94,7 +124,13 @@ def list_playlists(
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
_summary(db, p, counts.get(p.id, 0), (p.id in member) if contains else None)
|
_summary(
|
||||||
|
db,
|
||||||
|
p,
|
||||||
|
counts.get(p.id, 0),
|
||||||
|
durations.get(p.id, 0),
|
||||||
|
(p.id in member) if contains else None,
|
||||||
|
)
|
||||||
for p in pls
|
for p in pls
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -339,7 +375,7 @@ def rename_playlist(
|
||||||
count = db.scalar(
|
count = db.scalar(
|
||||||
select(func.count()).where(PlaylistItem.playlist_id == pl.id)
|
select(func.count()).where(PlaylistItem.playlist_id == pl.id)
|
||||||
)
|
)
|
||||||
return _summary(db, pl, count or 0)
|
return _summary(db, pl, count or 0, _total_duration(db, pl.id))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{playlist_id}")
|
@router.delete("/{playlist_id}")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
|
@ -17,10 +17,13 @@ import {
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import {
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
Check,
|
Check,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
ListPlus,
|
ListPlus,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Pin,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
|
@ -37,29 +40,34 @@ import PlayerModal from "./PlayerModal";
|
||||||
import UndoToolbar from "./UndoToolbar";
|
import UndoToolbar from "./UndoToolbar";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
|
||||||
type SortMode = "manual" | "title-asc" | "title-desc" | "dur-asc" | "dur-desc";
|
type SortKey = "manual" | "title" | "duration" | "channel";
|
||||||
|
type SortDir = "asc" | "desc";
|
||||||
|
|
||||||
function comparator(mode: SortMode): ((a: Video, b: Video) => number) | null {
|
function comparator(key: SortKey, dir: SortDir): ((a: Video, b: Video) => number) | null {
|
||||||
switch (mode) {
|
if (key === "manual") return null;
|
||||||
case "title-asc":
|
const sign = dir === "asc" ? 1 : -1;
|
||||||
return (a, b) => (a.title ?? "").localeCompare(b.title ?? "");
|
if (key === "duration")
|
||||||
case "title-desc":
|
return (a, b) => {
|
||||||
return (a, b) => (b.title ?? "").localeCompare(a.title ?? "");
|
const av = a.duration_seconds;
|
||||||
case "dur-asc":
|
const bv = b.duration_seconds;
|
||||||
return (a, b) => (a.duration_seconds ?? Infinity) - (b.duration_seconds ?? Infinity);
|
if (av == null && bv == null) return 0;
|
||||||
case "dur-desc":
|
if (av == null) return 1; // nulls always last, both directions
|
||||||
return (a, b) => (b.duration_seconds ?? -Infinity) - (a.duration_seconds ?? -Infinity);
|
if (bv == null) return -1;
|
||||||
default:
|
return sign * (av - bv);
|
||||||
return null;
|
};
|
||||||
}
|
return (a, b) => {
|
||||||
|
const av = (key === "channel" ? a.channel_title : a.title) ?? "";
|
||||||
|
const bv = (key === "channel" ? b.channel_title : b.title) ?? "";
|
||||||
|
return sign * av.localeCompare(bv);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort a playlist's items. When `group` is on, items are grouped by channel (groups ordered
|
// Sort a playlist's items. When `group` is on, items are grouped by channel (groups ordered
|
||||||
// by channel name A–Z) and the chosen sort is applied within each group; with sort "manual"
|
// by channel name in the chosen direction) and the chosen sort is applied within each group;
|
||||||
// the within-group order is left as-is. Returns the same array reference when nothing would
|
// with key "manual" the within-group order is left as-is. Returns the same array reference
|
||||||
// change, so it doesn't create a spurious undo entry.
|
// when nothing would change, so it doesn't create a spurious undo entry.
|
||||||
function sortItems(items: Video[], mode: SortMode, group: boolean): Video[] {
|
function sortItems(items: Video[], key: SortKey, dir: SortDir, group: boolean): Video[] {
|
||||||
const cmp = comparator(mode);
|
const cmp = comparator(key, dir);
|
||||||
if (!group) return cmp ? [...items].sort(cmp) : items;
|
if (!group) return cmp ? [...items].sort(cmp) : items;
|
||||||
const groups = new Map<string, Video[]>();
|
const groups = new Map<string, Video[]>();
|
||||||
for (const v of items) {
|
for (const v of items) {
|
||||||
|
|
@ -68,7 +76,8 @@ function sortItems(items: Video[], mode: SortMode, group: boolean): Video[] {
|
||||||
if (g) g.push(v);
|
if (g) g.push(v);
|
||||||
else groups.set(k, [v]);
|
else groups.set(k, [v]);
|
||||||
}
|
}
|
||||||
const keys = [...groups.keys()].sort((a, b) => a.localeCompare(b));
|
const sign = dir === "asc" ? 1 : -1;
|
||||||
|
const keys = [...groups.keys()].sort((a, b) => sign * a.localeCompare(b));
|
||||||
const out: Video[] = [];
|
const out: Video[] = [];
|
||||||
for (const k of keys) {
|
for (const k of keys) {
|
||||||
const g = groups.get(k)!;
|
const g = groups.get(k)!;
|
||||||
|
|
@ -83,6 +92,9 @@ const idsKey = (items: Video[]) =>
|
||||||
.sort()
|
.sort()
|
||||||
.join(",");
|
.join(",");
|
||||||
|
|
||||||
|
type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: SortDir; dirtyFirst: boolean };
|
||||||
|
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
|
||||||
|
|
||||||
function Row({
|
function Row({
|
||||||
video,
|
video,
|
||||||
index,
|
index,
|
||||||
|
|
@ -160,7 +172,29 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
// Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL).
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(() => {
|
||||||
|
const s = localStorage.getItem("siftlode.playlist");
|
||||||
|
return s ? Number(s) : null;
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedId != null) localStorage.setItem("siftlode.playlist", String(selectedId));
|
||||||
|
}, [selectedId]);
|
||||||
|
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const scrolledRef = useRef(false);
|
||||||
|
// How the left playlist rail is ordered (persisted).
|
||||||
|
const [plSort, setPlSort] = useState<PlSort>(() => {
|
||||||
|
try {
|
||||||
|
const s = localStorage.getItem("siftlode.plSort");
|
||||||
|
if (s) return { ...PL_SORT_DEFAULT, ...JSON.parse(s) };
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return PL_SORT_DEFAULT;
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("siftlode.plSort", JSON.stringify(plSort));
|
||||||
|
}, [plSort]);
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
const [renameValue, setRenameValue] = useState("");
|
const [renameValue, setRenameValue] = useState("");
|
||||||
|
|
@ -175,7 +209,8 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const items = order.value;
|
const items = order.value;
|
||||||
const [sortMode, setSortMode] = useState<SortMode>("manual");
|
const [sortKey, setSortKey] = useState<SortKey>("manual");
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||||
const [groupBy, setGroupBy] = useState(false);
|
const [groupBy, setGroupBy] = useState(false);
|
||||||
// Tracks the membership (id set) currently loaded into `order`, so a refetch that only
|
// Tracks the membership (id set) currently loaded into `order`, so a refetch that only
|
||||||
// re-confirms our own reorder doesn't wipe the undo history — we reset history only when
|
// re-confirms our own reorder doesn't wipe the undo history — we reset history only when
|
||||||
|
|
@ -225,20 +260,21 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
||||||
|
|
||||||
// Reset the sort controls when switching playlists (the order itself is per-playlist).
|
// Reset the sort controls when switching playlists (the order itself is per-playlist).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSortMode("manual");
|
setSortKey("manual");
|
||||||
|
setSortDir("asc");
|
||||||
setGroupBy(false);
|
setGroupBy(false);
|
||||||
}, [selectedId]);
|
}, [selectedId]);
|
||||||
|
|
||||||
function applySort(mode: SortMode, group: boolean) {
|
function applySort(key: SortKey, dir: SortDir, group: boolean) {
|
||||||
order.set(sortItems(items, mode, group));
|
order.set(sortItems(items, key, dir, group));
|
||||||
}
|
}
|
||||||
const undo = () => {
|
const undo = () => {
|
||||||
setSortMode("manual");
|
setSortKey("manual");
|
||||||
setGroupBy(false);
|
setGroupBy(false);
|
||||||
order.undo();
|
order.undo();
|
||||||
};
|
};
|
||||||
const redo = () => {
|
const redo = () => {
|
||||||
setSortMode("manual");
|
setSortKey("manual");
|
||||||
setGroupBy(false);
|
setGroupBy(false);
|
||||||
order.redo();
|
order.redo();
|
||||||
};
|
};
|
||||||
|
|
@ -250,6 +286,35 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
||||||
// Watch later is a built-in playlist: show a localized name and no rename/delete.
|
// Watch later is a built-in playlist: show a localized name and no rename/delete.
|
||||||
const plName = (p: { kind: string; name: string }) =>
|
const plName = (p: { kind: string; name: string }) =>
|
||||||
p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
|
p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
|
||||||
|
|
||||||
|
// The left rail in the chosen order. "custom" keeps the server position order (Array.sort
|
||||||
|
// is stable); "dirty first" floats playlists with unpushed edits to the top.
|
||||||
|
const sortedPlaylists = useMemo(() => {
|
||||||
|
const sign = plSort.dir === "asc" ? 1 : -1;
|
||||||
|
return [...playlists].sort((a, b) => {
|
||||||
|
if (plSort.dirtyFirst && a.dirty !== b.dirty) return a.dirty ? -1 : 1;
|
||||||
|
switch (plSort.key) {
|
||||||
|
case "name":
|
||||||
|
return sign * plName(a).localeCompare(plName(b));
|
||||||
|
case "count":
|
||||||
|
return sign * (a.item_count - b.item_count);
|
||||||
|
case "duration":
|
||||||
|
return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0));
|
||||||
|
default:
|
||||||
|
return 0; // custom: server order
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [playlists, plSort]);
|
||||||
|
|
||||||
|
// Scroll the restored selection into view once after the rail first renders.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scrolledRef.current && selectedRef.current) {
|
||||||
|
selectedRef.current.scrollIntoView({ block: "nearest" });
|
||||||
|
scrolledRef.current = true;
|
||||||
|
}
|
||||||
|
}, [sortedPlaylists]);
|
||||||
|
|
||||||
const builtin = detail?.kind === "watch_later";
|
const builtin = detail?.kind === "watch_later";
|
||||||
// YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips
|
// YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips
|
||||||
// a dirty mirror so it won't clobber unpushed edits); the chip just marks their origin.
|
// a dirty mirror so it won't clobber unpushed edits); the chip just marks their origin.
|
||||||
|
|
@ -349,7 +414,7 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
||||||
const newIndex = items.findIndex((v) => v.id === over.id);
|
const newIndex = items.findIndex((v) => v.id === over.id);
|
||||||
if (oldIndex < 0 || newIndex < 0) return;
|
if (oldIndex < 0 || newIndex < 0) return;
|
||||||
// A manual drag breaks any active sort/grouping; reflect that in the controls.
|
// A manual drag breaks any active sort/grouping; reflect that in the controls.
|
||||||
setSortMode("manual");
|
setSortKey("manual");
|
||||||
setGroupBy(false);
|
setGroupBy(false);
|
||||||
order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists
|
order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists
|
||||||
}
|
}
|
||||||
|
|
@ -432,13 +497,56 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
||||||
<Youtube className="w-3.5 h-3.5" />
|
<Youtube className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{playlists.length > 1 && (
|
||||||
|
<div className="flex items-center gap-1 mb-2">
|
||||||
|
<select
|
||||||
|
value={plSort.key}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPlSort({ ...plSort, key: e.target.value as PlSort["key"] })
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="custom">{t("playlists.railSortCustom")}</option>
|
||||||
|
<option value="name">{t("playlists.railSortName")}</option>
|
||||||
|
<option value="count">{t("playlists.railSortCount")}</option>
|
||||||
|
<option value="duration">{t("playlists.railSortDuration")}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setPlSort({ ...plSort, dir: plSort.dir === "asc" ? "desc" : "asc" })
|
||||||
|
}
|
||||||
|
disabled={plSort.key === "custom"}
|
||||||
|
title={plSort.dir === "asc" ? t("playlists.dirAsc") : t("playlists.dirDesc")}
|
||||||
|
className="p-1 rounded-md border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition"
|
||||||
|
>
|
||||||
|
{plSort.dir === "asc" ? (
|
||||||
|
<ArrowUp className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
|
||||||
|
title={t("playlists.dirtyFirst")}
|
||||||
|
aria-pressed={plSort.dirtyFirst}
|
||||||
|
className={`p-1 rounded-md border transition ${
|
||||||
|
plSort.dirtyFirst
|
||||||
|
? "border-accent text-accent"
|
||||||
|
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Pin className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{playlists.length === 0 && !listQuery.isLoading && (
|
{playlists.length === 0 && !listQuery.isLoading && (
|
||||||
<div className="text-xs text-muted px-1 py-2">{t("playlists.noneYet")}</div>
|
<div className="text-xs text-muted px-1 py-2">{t("playlists.noneYet")}</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{playlists.map((pl) => (
|
{sortedPlaylists.map((pl) => (
|
||||||
<button
|
<button
|
||||||
key={pl.id}
|
key={pl.id}
|
||||||
|
ref={pl.id === selectedId ? selectedRef : null}
|
||||||
onClick={() => setSelectedId(pl.id)}
|
onClick={() => setSelectedId(pl.id)}
|
||||||
className={`w-full flex items-center gap-2.5 p-2 rounded-lg border transition text-left ${
|
className={`w-full flex items-center gap-2.5 p-2 rounded-lg border transition text-left ${
|
||||||
pl.id === selectedId
|
pl.id === selectedId
|
||||||
|
|
@ -462,6 +570,8 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-muted">
|
<div className="text-[11px] text-muted">
|
||||||
{t("playlists.itemCount", { count: pl.item_count })}
|
{t("playlists.itemCount", { count: pl.item_count })}
|
||||||
|
{pl.total_duration_seconds > 0 &&
|
||||||
|
` · ${formatDuration(pl.total_duration_seconds)}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -589,27 +699,42 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
||||||
<div className="flex items-center gap-2 mb-3 flex-wrap max-w-3xl">
|
<div className="flex items-center gap-2 mb-3 flex-wrap max-w-3xl">
|
||||||
<span className="text-xs text-muted">{t("playlists.sortLabel")}</span>
|
<span className="text-xs text-muted">{t("playlists.sortLabel")}</span>
|
||||||
<select
|
<select
|
||||||
value={sortMode}
|
value={sortKey}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const m = e.target.value as SortMode;
|
const k = e.target.value as SortKey;
|
||||||
setSortMode(m);
|
setSortKey(k);
|
||||||
applySort(m, groupBy);
|
applySort(k, sortDir, groupBy);
|
||||||
}}
|
}}
|
||||||
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-none focus:border-accent"
|
||||||
>
|
>
|
||||||
<option value="manual">{t("playlists.sortManual")}</option>
|
<option value="manual">{t("playlists.sortManual")}</option>
|
||||||
<option value="title-asc">{t("playlists.sortTitleAsc")}</option>
|
<option value="title">{t("playlists.sortTitle")}</option>
|
||||||
<option value="title-desc">{t("playlists.sortTitleDesc")}</option>
|
<option value="duration">{t("playlists.sortDuration")}</option>
|
||||||
<option value="dur-asc">{t("playlists.sortDurAsc")}</option>
|
<option value="channel">{t("playlists.sortChannel")}</option>
|
||||||
<option value="dur-desc">{t("playlists.sortDurDesc")}</option>
|
|
||||||
</select>
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const d = sortDir === "asc" ? "desc" : "asc";
|
||||||
|
setSortDir(d);
|
||||||
|
if (sortKey !== "manual" || groupBy) applySort(sortKey, d, groupBy);
|
||||||
|
}}
|
||||||
|
disabled={sortKey === "manual" && !groupBy}
|
||||||
|
title={sortDir === "asc" ? t("playlists.dirAsc") : t("playlists.dirDesc")}
|
||||||
|
className="p-1.5 rounded-lg border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition"
|
||||||
|
>
|
||||||
|
{sortDir === "asc" ? (
|
||||||
|
<ArrowUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
|
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={groupBy}
|
checked={groupBy}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setGroupBy(e.target.checked);
|
setGroupBy(e.target.checked);
|
||||||
applySort(sortMode, e.target.checked);
|
applySort(sortKey, sortDir, e.target.checked);
|
||||||
}}
|
}}
|
||||||
className="accent-accent"
|
className="accent-accent"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,15 @@
|
||||||
"deleteYtFailed": "Löschen auf YouTube fehlgeschlagen; nur hier entfernt.",
|
"deleteYtFailed": "Löschen auf YouTube fehlgeschlagen; nur hier entfernt.",
|
||||||
"sortLabel": "Sortierung",
|
"sortLabel": "Sortierung",
|
||||||
"sortManual": "Eigene Reihenfolge",
|
"sortManual": "Eigene Reihenfolge",
|
||||||
"sortTitleAsc": "Titel A–Z",
|
"sortTitle": "Titel",
|
||||||
"sortTitleDesc": "Titel Z–A",
|
"sortDuration": "Dauer",
|
||||||
"sortDurAsc": "Kürzeste zuerst",
|
"sortChannel": "Kanal",
|
||||||
"sortDurDesc": "Längste zuerst",
|
"dirAsc": "Aufsteigend",
|
||||||
"groupByChannel": "Nach Kanal gruppieren"
|
"dirDesc": "Absteigend",
|
||||||
|
"groupByChannel": "Nach Kanal gruppieren",
|
||||||
|
"railSortCustom": "Eigene Reihenfolge",
|
||||||
|
"railSortName": "Name",
|
||||||
|
"railSortCount": "Anzahl",
|
||||||
|
"railSortDuration": "Gesamtlänge",
|
||||||
|
"dirtyFirst": "Nicht synchronisierte zuerst"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,15 @@
|
||||||
"deleteYtFailed": "Couldn't delete on YouTube; removed here only.",
|
"deleteYtFailed": "Couldn't delete on YouTube; removed here only.",
|
||||||
"sortLabel": "Sort",
|
"sortLabel": "Sort",
|
||||||
"sortManual": "Custom order",
|
"sortManual": "Custom order",
|
||||||
"sortTitleAsc": "Title A–Z",
|
"sortTitle": "Title",
|
||||||
"sortTitleDesc": "Title Z–A",
|
"sortDuration": "Duration",
|
||||||
"sortDurAsc": "Shortest first",
|
"sortChannel": "Channel",
|
||||||
"sortDurDesc": "Longest first",
|
"dirAsc": "Ascending",
|
||||||
"groupByChannel": "Group by channel"
|
"dirDesc": "Descending",
|
||||||
|
"groupByChannel": "Group by channel",
|
||||||
|
"railSortCustom": "Custom order",
|
||||||
|
"railSortName": "Name",
|
||||||
|
"railSortCount": "Item count",
|
||||||
|
"railSortDuration": "Total length",
|
||||||
|
"dirtyFirst": "Unsynced first"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,15 @@
|
||||||
"deleteYtFailed": "Nem sikerült törölni a YouTube-on; csak itt lett eltávolítva.",
|
"deleteYtFailed": "Nem sikerült törölni a YouTube-on; csak itt lett eltávolítva.",
|
||||||
"sortLabel": "Rendezés",
|
"sortLabel": "Rendezés",
|
||||||
"sortManual": "Egyéni sorrend",
|
"sortManual": "Egyéni sorrend",
|
||||||
"sortTitleAsc": "Cím A–Z",
|
"sortTitle": "Cím",
|
||||||
"sortTitleDesc": "Cím Z–A",
|
"sortDuration": "Hossz",
|
||||||
"sortDurAsc": "Legrövidebb elöl",
|
"sortChannel": "Csatorna",
|
||||||
"sortDurDesc": "Leghosszabb elöl",
|
"dirAsc": "Növekvő",
|
||||||
"groupByChannel": "Csoportosítás csatorna szerint"
|
"dirDesc": "Csökkenő",
|
||||||
|
"groupByChannel": "Csoportosítás csatorna szerint",
|
||||||
|
"railSortCustom": "Egyéni sorrend",
|
||||||
|
"railSortName": "Név",
|
||||||
|
"railSortCount": "Elemszám",
|
||||||
|
"railSortDuration": "Összhossz",
|
||||||
|
"dirtyFirst": "Nem szinkronizáltak elöl"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ export interface Playlist {
|
||||||
yt_playlist_id: string | null;
|
yt_playlist_id: string | null;
|
||||||
dirty: boolean; // linked local playlist has edits not yet pushed to YouTube
|
dirty: boolean; // linked local playlist has edits not yet pushed to YouTube
|
||||||
item_count: number;
|
item_count: number;
|
||||||
|
total_duration_seconds: number;
|
||||||
cover_thumbnail: string | null;
|
cover_thumbnail: string | null;
|
||||||
has_video?: boolean; // only set when listed with ?contains=<video_id>
|
has_video?: boolean; // only set when listed with ?contains=<video_id>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue