@@ -1,4 +1,4 @@
import { useEffect , useRef , useState } from "react" ;
import { useEffect , useMemo , useRef , useState } from "react" ;
import { useTranslation } from "react-i18next" ;
import { useMutation , useQuery , useQueryClient } from "@tanstack/react-query" ;
import {
@@ -17,10 +17,13 @@ import {
} from "@dnd-kit/sortable" ;
import { CSS } from "@dnd-kit/utilities" ;
import {
ArrowDown ,
ArrowUp ,
Check ,
GripVertical ,
ListPlus ,
Pencil ,
Pin ,
Play ,
Plus ,
RefreshCw ,
@@ -37,29 +40,34 @@ import PlayerModal from "./PlayerModal";
import UndoToolbar from "./UndoToolbar" ;
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 {
switch ( mode ) {
case "title-asc" :
return ( a , b ) = > ( a . title ? ? "" ) . localeCompare ( b . title ? ? " ") ;
case "title-desc" :
return ( a , b ) = > ( b . title ? ? "" ) . localeCompare ( a . title ? ? "" ) ;
case "dur-asc" :
return ( a , b ) = > ( a . duration_seconds ? ? Infinity ) - ( b . duration_seconds ? ? Infinity ) ;
case "dur-desc" :
return ( a , b ) = > ( b . duration_seconds ? ? - Infinity ) - ( a . duration_seconds ? ? - Infinity ) ;
default :
return null ;
}
function comparator ( key : SortKey , dir : SortDir ) : ( ( a : Video , b : Video ) = > number ) | null {
if ( key === "manual" ) return null ;
const sign = dir === "asc" ? 1 : - 1 ;
if ( key === "duration ")
return ( a , b ) = > {
const av = a . duration_seconds ;
const bv = b . duration_seconds ;
if ( av == null && bv == null ) return 0 ;
if ( av == null ) return 1 ; // nulls always last, both directions
if ( bv == null ) return - 1 ;
return sign * ( av - bv ) ;
} ;
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
// by channel name A– Z ) and the chosen sort is applied within each group; with sort "manual"
// the within-group order is left as-is. Returns the same array reference when nothing would
// change, so it doesn't create a spurious undo entry.
function sortItems ( items : Video [ ] , mode : SortMode , group : boolean ) : Video [ ] {
const cmp = comparator ( mode ) ;
// by channel name in the chosen direction ) and the chosen sort is applied within each group;
// with key "manual" the within-group order is left as-is. Returns the same array reference
// when nothing would change, so it doesn't create a spurious undo entry.
function sortItems ( items : Video [ ] , key : SortKey , dir : SortDir , group : boolean ) : Video [ ] {
const cmp = comparator ( key , dir ) ;
if ( ! group ) return cmp ? [ . . . items ] . sort ( cmp ) : items ;
const groups = new Map < string , Video [ ] > ( ) ;
for ( const v of items ) {
@@ -68,7 +76,8 @@ function sortItems(items: Video[], mode: SortMode, group: boolean): Video[] {
if ( g ) g . push ( 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 [ ] = [ ] ;
for ( const k of keys ) {
const g = groups . get ( k ) ! ;
@@ -83,6 +92,9 @@ const idsKey = (items: Video[]) =>
. sort ( )
. 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 ( {
video ,
index ,
@@ -160,7 +172,29 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
const { t } = useTranslation ( ) ;
const qc = useQueryClient ( ) ;
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 [ renaming , setRenaming ] = useState ( false ) ;
const [ renameValue , setRenameValue ] = useState ( "" ) ;
@@ -175,7 +209,8 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
} ,
} ) ;
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 ) ;
// 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
@@ -225,20 +260,21 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
// Reset the sort controls when switching playlists (the order itself is per-playlist).
useEffect ( ( ) = > {
setSortMode ( "manual" ) ;
setSortKey ( "manual" ) ;
setSortDir ( "asc" ) ;
setGroupBy ( false ) ;
} , [ selectedId ] ) ;
function applySort ( mode : SortMode , group : boolean ) {
order . set ( sortItems ( items , mode , group ) ) ;
function applySort ( key : SortKey , dir : SortDir , group : boolean ) {
order . set ( sortItems ( items , key , dir , group ) ) ;
}
const undo = ( ) = > {
setSortMode ( "manual" ) ;
setSortKey ( "manual" ) ;
setGroupBy ( false ) ;
order . undo ( ) ;
} ;
const redo = ( ) = > {
setSortMode ( "manual" ) ;
setSortKey ( "manual" ) ;
setGroupBy ( false ) ;
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.
const plName = ( p : { kind : string ; name : string } ) = >
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" ;
// 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.
@@ -349,7 +414,7 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
const newIndex = items . findIndex ( ( v ) = > v . id === over . id ) ;
if ( oldIndex < 0 || newIndex < 0 ) return ;
// A manual drag breaks any active sort/grouping; reflect that in the controls.
setSortMode ( "manual" ) ;
setSortKey ( "manual" ) ;
setGroupBy ( false ) ;
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" / >
< / button >
< / 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 && (
< div className = "text-xs text-muted px-1 py-2" > { t ( "playlists.noneYet" ) } < / div >
) }
< div className = "space-y-1" >
{ playlists . map ( ( pl ) = > (
{ sortedPlaylists . map ( ( pl ) = > (
< button
key = { pl . id }
ref = { pl . id === selectedId ? selectedRef : null }
onClick = { ( ) = > setSelectedId ( pl . id ) }
className = { ` w-full flex items-center gap-2.5 p-2 rounded-lg border transition text-left ${
pl . id === selectedId
@@ -462,6 +570,8 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
< / div >
< div className = "text-[11px] text-muted" >
{ t ( "playlists.itemCount" , { count : pl.item_count } ) }
{ pl . total_duration_seconds > 0 &&
` · ${ formatDuration ( pl . total_duration_seconds ) } ` }
< / div >
< / div >
< / 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" >
< span className = "text-xs text-muted" > { t ( "playlists.sortLabel" ) } < / span >
< select
value = { sortMode }
value = { sortKey }
onChange = { ( e ) = > {
const m = e . target . value as SortMode ;
setSortMode ( m ) ;
applySort ( m , groupBy ) ;
const k = e . target . value as SortKey ;
setSortKey ( k ) ;
applySort ( k , sortDir , groupBy ) ;
} }
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 = "title-asc " > { t ( "playlists.sortTitleAsc " ) } < / option >
< option value = "title-desc " > { t ( "playlists.sortTitleDesc " ) } < / option >
< option value = "dur-asc " > { t ( "playlists.sortDurAsc " ) } < / option >
< option value = "dur-desc" > { t ( "playlists.sortDurDesc" ) } < / option >
< option value = "title" > { t ( "playlists.sortTitle" ) } < / option >
< option value = "duration " > { t ( "playlists.sortDuration " ) } < / option >
< option value = "channel " > { t ( "playlists.sortChannel " ) } < / option >
< / 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" >
< input
type = "checkbox"
checked = { groupBy }
onChange = { ( e ) = > {
setGroupBy ( e . target . checked ) ;
applySort ( sortMode , e . target . checked ) ;
applySort ( sortKey , sortDir , e . target . checked ) ;
} }
className = "accent-accent"
/ >