diff --git a/README.md b/README.md index 51ae51c1..29f781c4 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,6 @@ services: plex-rewind: image: ghcr.io/raunot/plex-rewind:latest # :develop for the latest development version container_name: plex-rewind - # user: 1000:1000 # change to your user and group id if you are running into permissions issues environment: - NEXTAUTH_SECRET= # (required) used to encrypt auth JWT token, generate one with `openssl rand -base64 32` - NEXTAUTH_URL=http://localhost:8383 # (required) change to your domain if you are exposing the app to the internet diff --git a/src/app/_components/AppProvider.tsx b/src/app/_components/AppProvider.tsx index 5170f5ae..afeeebb8 100644 --- a/src/app/_components/AppProvider.tsx +++ b/src/app/_components/AppProvider.tsx @@ -1,7 +1,6 @@ 'use client' import { Version } from '@/types' -import { DashboardSearchParams } from '@/types/dashboard' import { Settings } from '@/types/settings' import { checkRequiredSettings } from '@/utils/helpers' import { @@ -14,22 +13,9 @@ import { useSession } from 'next-auth/react' import Image from 'next/image' import Link from 'next/link' import { usePathname } from 'next/navigation' -import { createContext, ReactNode, useEffect, useState } from 'react' +import { ReactNode, useEffect, useState } from 'react' import stars from '../_assets/stars.png' - -type GlobalContextProps = { - isDashboardPersonal: boolean - setIsDashboardPersonal: (isDashboardPersonal: boolean) => void - period: DashboardSearchParams['period'] - setPeriod: (period: DashboardSearchParams['period']) => void -} - -export const GlobalContext = createContext({ - isDashboardPersonal: false, - setIsDashboardPersonal: () => {}, - period: 'custom', - setPeriod: () => {}, -}) +import GlobalContextProvider from './GlobalContextProvider' type Props = { children: ReactNode @@ -45,24 +31,6 @@ export default function AppProvider({ children, settings, version }: Props) { pathname.startsWith('/settings'), ) const [settingsLink, setSettingsLink] = useState('/settings/general') - const [isDashboardPersonal, setIsDashboardPersonal] = useState( - () => { - if (typeof window !== 'undefined') { - return localStorage.getItem('dashboardPersonal') === 'true' - } - - return false - }, - ) - const [period, setPeriod] = useState(() => { - if (typeof window !== 'undefined') { - return localStorage.getItem( - 'dashboardPeriod', - ) as DashboardSearchParams['period'] - } - - return undefined - }) useEffect(() => { switch (true) { @@ -80,23 +48,8 @@ export default function AppProvider({ children, settings, version }: Props) { setIsSettings(pathname.startsWith('/settings')) }, [pathname]) - useEffect(() => { - localStorage.setItem('dashboardPersonal', isDashboardPersonal.toString()) - }, [isDashboardPersonal]) - - useEffect(() => { - localStorage.setItem('dashboardPeriod', period || '') - }, [period]) - return ( - +
- + ) } diff --git a/src/app/_components/GlobalContextProvider.tsx b/src/app/_components/GlobalContextProvider.tsx new file mode 100644 index 00000000..b822a7f8 --- /dev/null +++ b/src/app/_components/GlobalContextProvider.tsx @@ -0,0 +1,91 @@ +'use client' + +import { DashboardSearchParams } from '@/types/dashboard' +import { createContext, ReactNode, useEffect, useState } from 'react' + +type GlobalContextProps = { + dashboard: { + isPersonal: DashboardSearchParams['personal'] + setIsPersonal: (isPersonal: DashboardSearchParams['personal']) => void + period: DashboardSearchParams['period'] + setPeriod: (period: DashboardSearchParams['period']) => void + sortBy: DashboardSearchParams['sortBy'] + setSortBy: (sortBy: DashboardSearchParams['sortBy']) => void + } +} + +export const GlobalContext = createContext({ + dashboard: { + isPersonal: undefined, + setIsPersonal: () => {}, + period: 'custom', + setPeriod: () => {}, + sortBy: undefined, + setSortBy: () => {}, + }, +}) + +type Props = { + children: ReactNode +} + +export default function GlobalContextProvider({ children }: Props) { + const [isPersonal, setIsPersonal] = useState< + DashboardSearchParams['personal'] + >(() => { + if (typeof window !== 'undefined') { + return localStorage.getItem( + 'dashboardPersonal', + ) as DashboardSearchParams['personal'] + } + + return undefined + }) + const [period, setPeriod] = useState(() => { + if (typeof window !== 'undefined') { + return localStorage.getItem( + 'dashboardPeriod', + ) as DashboardSearchParams['period'] + } + + return undefined + }) + const [sortBy, setSortBy] = useState(() => { + if (typeof window !== 'undefined') { + return localStorage.getItem( + 'dashboardSort', + ) as DashboardSearchParams['sortBy'] + } + + return undefined + }) + + useEffect(() => { + localStorage.setItem('dashboardPersonal', isPersonal || '') + }, [isPersonal]) + + useEffect(() => { + localStorage.setItem('dashboardPeriod', period || '') + }, [period]) + + useEffect(() => { + localStorage.setItem('dashboardSort', sortBy || '') + }, [sortBy]) + + return ( + + {children} + + ) +} diff --git a/src/app/_components/Home.tsx b/src/app/_components/Home.tsx index e41b712e..92857bb8 100644 --- a/src/app/_components/Home.tsx +++ b/src/app/_components/Home.tsx @@ -12,7 +12,7 @@ import { signOut, useSession } from 'next-auth/react' import Image from 'next/image' import Link from 'next/link' import { useContext } from 'react' -import { GlobalContext } from './AppProvider' +import { GlobalContext } from './GlobalContextProvider' type Props = { settings: Settings @@ -22,7 +22,9 @@ type Props = { export default function Home({ settings, libraries }: Props) { const { isLoading, handleLogin } = usePlexAuth() const missingSetting = checkRequiredSettings(settings) - const { isDashboardPersonal, period } = useContext(GlobalContext) + const { + dashboard: { isPersonal, period, sortBy }, + } = useContext(GlobalContext) const { data: session, status } = useSession() const isLoggedIn = status === 'authenticated' const dashboardSlug = kebabCase( @@ -36,8 +38,9 @@ export default function Home({ settings, libraries }: Props) { (isLoggedIn || settings.general.isOutsideAccess) && dashboardSlug const dashboardParams = new URLSearchParams({ - ...(isDashboardPersonal && { personal: 'true' }), + ...(isPersonal && { personal: 'true' }), ...(period && { period }), + ...(sortBy && settings.dashboard.isSortByPlaysActive && { sortBy }), }) if (isLoading || status === 'loading') { diff --git a/src/app/dashboard/[slug]/page.tsx b/src/app/dashboard/[slug]/page.tsx index 9a2221f6..49cd2af4 100644 --- a/src/app/dashboard/[slug]/page.tsx +++ b/src/app/dashboard/[slug]/page.tsx @@ -44,8 +44,15 @@ async function DashboardContent({ params, searchParams }: Props) { const session = await getServerSession(authOptions) const period = getPeriod(searchParams, settings) const isPersonal = searchParams.personal === 'true' + const sortByPlays = + searchParams.sortBy === 'plays' && settings.dashboard.isSortByPlaysActive const [items, totalDuration, totalSize, serverId] = await Promise.all([ - getItems(library, period.daysAgo, isPersonal && session?.user.id), + getItems( + library, + period.daysAgo, + isPersonal && session?.user.id, + sortByPlays, + ), getTotalDuration( library, period.string, @@ -82,7 +89,7 @@ export default function DashboardPage({ params, searchParams }: Props) { return ( } - key={`period-${searchParams.period}-personal-${searchParams.personal}`} + key={`period-${searchParams.period}-personal-${searchParams.personal}-sortBy-${searchParams.sortBy}`} > diff --git a/src/app/dashboard/_components/Dashboard.tsx b/src/app/dashboard/_components/Dashboard.tsx index 3c40248f..b9b4880f 100644 --- a/src/app/dashboard/_components/Dashboard.tsx +++ b/src/app/dashboard/_components/Dashboard.tsx @@ -11,7 +11,7 @@ import { UserIcon, } from '@heroicons/react/24/outline' import { Suspense } from 'react' -import DashboardPersonalToggle from './DashboardPersonalToggle' +import DashboardFilters from './DashboardFilters' type Props = { title: string @@ -36,18 +36,27 @@ export default function Dashboard({ settings, isLoggedIn, }: Props) { + function renderFilters(className?: string) { + if (type !== 'users' && isLoggedIn) { + return ( + + + + ) + } + } + return ( <> -
-

+
+

{getTitleIcon(type)} {title}

- {type !== 'users' && isLoggedIn && ( - - - - )} + {renderFilters('hidden sm:flex')}
    {totalSize && ( @@ -96,13 +105,15 @@ export default function Dashboard({

)} + + {renderFilters('flex sm:hidden justify-end pt-6')} ) } function getTitleIcon(type: string) { const className = - 'mr-1 sm:mr-2 size-8 sm:size-10 stroke-1 text-black shrink-0 pb-1 -ml-1' + 'mr-1 sm:mr-2 size-8 sm:size-10 stroke-1 text-black shrink-0 -ml-0.5 sm:-ml-1' switch (type) { case 'movie': diff --git a/src/app/dashboard/_components/DashboardFilters.tsx b/src/app/dashboard/_components/DashboardFilters.tsx new file mode 100644 index 00000000..b2474785 --- /dev/null +++ b/src/app/dashboard/_components/DashboardFilters.tsx @@ -0,0 +1,104 @@ +'use client' + +import { GlobalContext } from '@/app/_components/GlobalContextProvider' +import clsx from 'clsx' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { ChangeEvent, useCallback, useContext, useEffect } from 'react' + +type Props = { + className?: string + isSortByPlaysActive: boolean +} + +export default function DashboardFilters({ + className, + isSortByPlaysActive, +}: Props) { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + const personalParam = searchParams.get('personal') + const sortByParam = searchParams.get('sortBy') + const { + dashboard: { isPersonal, setIsPersonal, sortBy, setSortBy }, + } = useContext(GlobalContext) + + // eslint-disable-next-line @stylistic/js/padding-line-between-statements + const updateURL = useCallback(() => { + const params = new URLSearchParams(searchParams.toString()) + + if (isPersonal === 'true') { + params.set('personal', 'true') + } else { + params.delete('personal') + } + + if (sortBy === 'plays') { + params.set('sortBy', 'plays') + } else { + params.delete('sortBy') + } + + const query = params.toString() ? `?${params.toString()}` : '' + + router.push(`${pathname}${query}`) + }, [isPersonal, sortBy, pathname, searchParams, router]) + + function handlePersonalChange(e: ChangeEvent) { + setIsPersonal(e.target.value === 'true' ? 'true' : undefined) + } + + function handleSortChange(e: ChangeEvent) { + setSortBy(e.target.value === 'plays' ? 'plays' : undefined) + } + + useEffect(() => { + updateURL() + }, [isPersonal, sortBy, updateURL]) + + useEffect(() => { + setIsPersonal(personalParam === 'true' ? 'true' : undefined) + setSortBy(sortByParam === 'plays' ? 'plays' : undefined) + }, [setIsPersonal, setSortBy, personalParam, sortByParam]) + + return ( +
+
+ +
+ +
+
+ {isSortByPlaysActive && ( +
+ +
+ +
+
+ )} +
+ ) +} diff --git a/src/app/dashboard/_components/DashboardPersonalToggle.tsx b/src/app/dashboard/_components/DashboardPersonalToggle.tsx deleted file mode 100644 index 601b7c5f..00000000 --- a/src/app/dashboard/_components/DashboardPersonalToggle.tsx +++ /dev/null @@ -1,56 +0,0 @@ -'use client' - -import { GlobalContext } from '@/app/_components/AppProvider' -import { usePathname, useRouter, useSearchParams } from 'next/navigation' -import { useCallback, useContext, useEffect } from 'react' -import { Switch } from 'react-aria-components' - -export default function DashboardPersonalToggle() { - const router = useRouter() - const pathname = usePathname() - const searchParams = useSearchParams() - const { isDashboardPersonal, setIsDashboardPersonal } = - useContext(GlobalContext) - - // eslint-disable-next-line @stylistic/js/padding-line-between-statements - const updateURL = useCallback(() => { - const currentParams = new URLSearchParams( - Array.from(searchParams.entries()), - ) - - if (isDashboardPersonal) { - currentParams.set('personal', 'true') - } else { - currentParams.delete('personal') - } - - const search = currentParams.toString() - const query = search ? `?${search}` : '' - - router.push(`${pathname}${query}`) - }, [isDashboardPersonal, router, pathname, searchParams]) - - useEffect(() => { - const currentParams = new URLSearchParams( - Array.from(searchParams.entries()), - ) - - setIsDashboardPersonal(currentParams.get('personal') === 'true') - }, [searchParams, setIsDashboardPersonal]) - - useEffect(() => { - updateURL() - }, [isDashboardPersonal, updateURL]) - - return ( - -
- Personal - - ) -} diff --git a/src/app/dashboard/_components/PeriodSelectContent.tsx b/src/app/dashboard/_components/PeriodSelectContent.tsx index 84b71554..d3123164 100644 --- a/src/app/dashboard/_components/PeriodSelectContent.tsx +++ b/src/app/dashboard/_components/PeriodSelectContent.tsx @@ -1,6 +1,6 @@ 'use client' -import { GlobalContext } from '@/app/_components/AppProvider' +import { GlobalContext } from '@/app/_components/GlobalContextProvider' import { DashboardSearchParams } from '@/types/dashboard' import { Settings } from '@/types/settings' import { pluralize } from '@/utils/formatting' @@ -30,10 +30,11 @@ function getPeriodValue(period: string, customPeriod: number): number { export default function PeriodSelectContent({ settings }: Props) { const pathname = usePathname() const searchParams = useSearchParams() - const { setPeriod } = useContext(GlobalContext) + const { + dashboard: { setPeriod }, + } = useContext(GlobalContext) const periodParam = searchParams.get('period') const customPeriod = parseInt(settings.dashboard.customPeriod) - // Replace '30 days' with custom period if it exists const periodOptions = [ { label: '7 days', value: '7days' }, { @@ -43,12 +44,11 @@ export default function PeriodSelectContent({ settings }: Props) { { label: 'Past year', value: 'pastYear' }, { label: 'All time', value: 'allTime' }, ] - const belongsToOptions = periodOptions.some( - (option) => option.value === periodParam, - ) - const isDefaultSelected = !periodParam || !belongsToOptions + const validPeriodValues = periodOptions.map((option) => option.value) + const isValidPeriod = periodParam && validPeriodValues.includes(periodParam) + const isDefaultSelected = !isValidPeriod - // Sort period options + // Sort period options by value periodOptions.sort((a, b) => { return ( getPeriodValue(a.value, customPeriod) - @@ -74,11 +74,11 @@ export default function PeriodSelectContent({ settings }: Props) { useEffect(() => { setPeriod( - belongsToOptions && periodParam + isValidPeriod ? (periodParam as DashboardSearchParams['period']) : undefined, ) - }, [periodParam, setPeriod, belongsToOptions]) + }, [periodParam, setPeriod, isValidPeriod]) return (
    diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fb67b896..d51c178a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -25,7 +25,6 @@ export const metadata: Metadata = { manifest: '/manifest.json', appleWebApp: { title: META_TITLE, - capable: true, statusBarStyle: 'black', }, openGraph: { diff --git a/src/app/settings/dashboard/_actions/updateDashboardSettings.ts b/src/app/settings/dashboard/_actions/updateDashboardSettings.ts index 19473df1..751ffad2 100644 --- a/src/app/settings/dashboard/_actions/updateDashboardSettings.ts +++ b/src/app/settings/dashboard/_actions/updateDashboardSettings.ts @@ -18,6 +18,7 @@ const schema = z.object({ activeTotalStatistics: z .array(z.enum(['size', 'duration', 'count', 'requests'])) .optional(), + isSortByPlaysActive: z.boolean().optional(), customPeriod: z .string() .refine( @@ -55,6 +56,7 @@ export default async function saveDashboardSettings( data.activeTotalStatistics = formData.getAll( 'activeTotalStatistics', ) as DashboardTotalStatistics + data.isSortByPlaysActive = formData.get('isSortByPlaysActive') === 'on' data.customPeriod = formData.get('customPeriod') as string data.startDate = formData.get('startDate') as string } diff --git a/src/app/settings/dashboard/_components/DashboardSettingsForm.tsx b/src/app/settings/dashboard/_components/DashboardSettingsForm.tsx index f135330c..a63a8105 100644 --- a/src/app/settings/dashboard/_components/DashboardSettingsForm.tsx +++ b/src/app/settings/dashboard/_components/DashboardSettingsForm.tsx @@ -114,6 +114,14 @@ export default function DashboardSettingsForm({ settings }: Props) {
+ +
+ Sort by plays filter +

Defaults

diff --git a/src/styles/globals.css b/src/styles/globals.css index 98b06aa4..35cfea23 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -129,6 +129,10 @@ &[required] + .label { @apply required; } + + &--small { + @apply px-2 py-1 text-sm; + } } .input-wrapper { @@ -143,6 +147,10 @@ content: ''; } + + &--small::after { + @apply right-7; + } } .label { @@ -289,4 +297,11 @@ select.input { background-image: url('data:image/svg+xml;charset=US-ASCII,'); background-position: right 12px center; background-size: 24px; + + &--small { + @apply pr-10; + + background-position: right 7px center; + background-size: 16px; + } } diff --git a/src/types/dashboard.d.ts b/src/types/dashboard.d.ts index 2a3f00ab..e1b2f7fb 100644 --- a/src/types/dashboard.d.ts +++ b/src/types/dashboard.d.ts @@ -1,4 +1,5 @@ export type DashboardSearchParams = { period?: '7days' | 'pastYear' | 'allTime' | 'custom' personal?: 'true' + sortBy?: 'plays' } diff --git a/src/types/settings.d.ts b/src/types/settings.d.ts index 22db1bcc..cc341cb9 100644 --- a/src/types/settings.d.ts +++ b/src/types/settings.d.ts @@ -49,6 +49,7 @@ export type DashboardSettings = { isUsersPageActive: boolean activeItemStatistics: DashboardItemStatistics activeTotalStatistics: DashboardTotalStatistics + isSortByPlaysActive: boolean customPeriod: string startDate: string complete: boolean diff --git a/src/utils/constants.ts b/src/utils/constants.ts index dfd7d915..27e11011 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -72,6 +72,7 @@ export const DEFAULT_SETTINGS: Settings = { 'requests', ], activeTotalStatistics: ['size', 'duration', 'count', 'requests'], + isSortByPlaysActive: true, customPeriod: '30', startDate: '2010-01-01', complete: false, diff --git a/src/utils/getDashboard.ts b/src/utils/getDashboard.ts index f98612b1..0673c67e 100644 --- a/src/utils/getDashboard.ts +++ b/src/utils/getDashboard.ts @@ -10,6 +10,7 @@ export async function getItems( library: TautulliLibrary, period: number, userId?: string, + sortByPlays?: boolean, ) { const sectionType = library.section_type const statIdMap = { @@ -25,7 +26,7 @@ export async function getItems( const itemsRes = await fetchTautulli('get_home_stats', { stat_id: statIdMap[sectionType], stats_count: 6, - stats_type: 'duration', + stats_type: sortByPlays ? 'plays' : 'duration', time_range: period, section_id: library.section_id, user_id: userId ? userId : '',