Overview
User components handle authentication flows, user-specific actions like watchlist and favorites, and profile management. These components integrate with TMDB’s authentication system and Zustand for state management.
Authentication Components
Simple login button that redirects to the login page with return URL.
Implementation
import { IonIcon } from "@ionic/react" ;
import { personCircleOutline } from "ionicons/icons" ;
import Link from "next/link" ;
import { usePathname , useSearchParams } from "next/navigation" ;
export default function LoginButton () {
const pathname = usePathname ();
const searchParams = useSearchParams (). toString ();
return (
< Link
id = "login"
href = {
pathname !== `/login`
? `/login?redirect_to= ${ pathname }${ searchParams ? `? ${ searchParams } ` : `` } `
: `/login`
}
prefetch = { false }
className = "btn btn-circle"
>
< IonIcon
icon = { personCircleOutline }
style = { { fontSize: 36 } }
/>
</ Link >
);
}
The redirect_to parameter ensures users return to their original page after logging in.
Displays user profile picture or initials with link to profile page.
Props
User object with properties:
username: User’s username
name: Display name
avatar: Avatar object with tmdb and gravatar properties
Implementation
export default function LogoutButton ({ user }) {
const pathname = usePathname ();
const searchParams = useSearchParams ();
const isTvPage = pathname . startsWith ( "/tv" );
const isProfilePage = pathname . startsWith ( `/profile` );
const [ profileImage , setProfileImage ] = useState ( null );
useEffect (() => {
const { avatar } = user ;
if ( avatar . tmdb . avatar_path ) {
setProfileImage (
`https://www.themoviedb.org/t/p/w64_and_h64_face ${ avatar . tmdb . avatar_path } `
);
}
if ( avatar . gravatar ) {
setProfileImage ( `https://gravatar.com/avatar/ ${ avatar . gravatar . hash } ` );
}
}, [ user ]);
return (
< Link
href = { {
pathname: `/profile` ,
query: ( isProfilePage && searchParams . get ( "type" ) === "tv" ) || isTvPage
? "type=tv"
: "" ,
} }
className = "btn btn-circle"
>
{ ! profileImage ? (
< div className = "avatar placeholder" >
< div className = "w-[36px] rounded-full bg-base-100" >
< span > { user . username . slice ( 0 , 2 ) } </ span >
</ div >
</ div >
) : (
< figure className = "avatar" >
< div className = "w-[36px] rounded-full" >
< img src = { profileImage } alt = { user . name } />
</ div >
</ figure >
) }
</ Link >
);
}
TMDB Avatar
Gravatar Fallback
Initials Fallback
Prioritizes TMDB avatar if available: `https://www.themoviedb.org/t/p/w64_and_h64_face ${ avatar . tmdb . avatar_path } `
Falls back to Gravatar: `https://gravatar.com/avatar/ ${ avatar . gravatar . hash } `
Shows first two letters of username if no image: < span > { user . username . slice ( 0 , 2 ) } </ span >
User Actions
Toggle button for adding/removing films from user’s watchlist.
Props
SWR cache key to invalidate on change
Film object with id property
Whether to show “Watchlist” text label
Implementation
User/Actions/WatchlistButton.jsx
import { userStore } from "@/zustand/userStore" ;
import axios from "axios" ;
import { useSWRConfig } from "swr" ;
export default function WatchlistButton ({
swrKey ,
film ,
watchlist ,
withText = true ,
className ,
}) {
const { user } = userStore ();
const { mutate } = useSWRConfig ();
const pathname = usePathname ();
const isTvPage = pathname . startsWith ( "/tv" );
const [ isAdded , setIsAdded ] = useState ( watchlist );
const [ isLoading , setIsLoading ] = useState ( false );
const handleWatchlist = async ( value ) => {
try {
setIsLoading ( true );
const { data : { watchlist } } = await axios . post (
`/api/account/ ${ user . id } /watchlist` ,
{
media_type: ! isTvPage ? "movie" : "tv" ,
media_id: film . id ,
watchlist: value ,
}
);
setIsLoading ( false );
setIsAdded ( watchlist );
mutate ( swrKey ); // Invalidate cache
} catch ( error ) {
console . error ( "Error adding to watchlist:" , error );
setIsLoading ( false );
}
};
return (
< button
onClick = { () => {
if ( user ) {
handleWatchlist ( ! isAdded );
} else {
document . getElementById ( "loginAlert" ). showModal ();
}
} }
className = "btn btn-ghost"
>
{ isLoading ? (
< span className = "loading loading-spinner" ></ span >
) : (
< IonIcon icon = { ! isAdded ? bookmarkOutline : bookmark } />
) }
{ withText && < span > Watchlist </ span > }
</ button >
);
}
After updating watchlist status, the component invalidates the SWR cache to refresh data: const { mutate } = useSWRConfig ();
// After API call succeeds:
mutate ( swrKey );
This ensures the UI updates across all components using the same data.
If no user is logged in, shows a login modal instead: onClick = {() => {
if ( user ) {
handleWatchlist ( ! isAdded );
} else {
document . getElementById ( "loginAlert" ). showModal ();
}
}}
Similar to WatchlistButton but for marking favorites.
API Endpoint : /api/account/${user.id}/favorite
Request Body :
{
"media_type" : "movie" | "tv" ,
"media_id" : 550 ,
"favorite" : true | false
}
UserRating
Component for users to rate films on a 1-10 scale.
API Endpoint : /api/${mediaType}/${filmId}/rating
Request Body :
User State Management
Popcorn Vision uses Zustand for global user state.
userStore
import { create } from "zustand" ;
export const userStore = create (( set ) => ({
user: null ,
setUser : ( user ) => set ({ user }),
}));
Usage
import { userStore } from "@/zustand/userStore" ;
export default function MyComponent () {
const { user , setUser } = userStore ();
// Check if user is logged in
if ( ! user ) {
return < LoginButton /> ;
}
return (
< div >
< h1 > Welcome, { user . name } ! </ h1 >
< LogoutButton user = { user } />
</ div >
);
}
UserProvider
Wrapper component that fetches and sets user data on mount.
Providers/UserProvider.jsx
import { userStore } from "@/zustand/userStore" ;
import { useAuth } from "@/hooks/auth" ;
export default function UserProvider ({ children }) {
const { user } = useAuth (); // Custom hook for fetching user
const { setUser } = userStore ();
useEffect (() => {
if ( user ) {
setUser ( user );
}
}, [ user , setUser ]);
return children ;
}
useAuth Hook
import useSWR from "swr" ;
import axios from "axios" ;
export function useAuth () {
const { data : user , error , mutate } = useSWR (
"/api/auth/user" ,
( url ) => axios . get ( url ). then (({ data }) => data ),
{
revalidateOnFocus: false ,
revalidateOnReconnect: false ,
}
);
return {
user ,
isLoading: ! error && ! user ,
isError: error ,
mutate ,
};
}
UserLocation
Component for detecting and storing user’s country for region-specific content (watch providers).
Implementation
import { USER_LOCATION } from "@/lib/constants" ;
import { useLocation } from "@/zustand/location" ;
import axios from "axios" ;
export default function UserLocation ({ children }) {
const { setLocation } = useLocation ();
const [ isLocationSaved , setIsLocationSaved ] = useState ( true );
useEffect (() => {
const userLocation = localStorage . getItem ( USER_LOCATION );
if ( ! userLocation ) {
setIsLocationSaved ( false );
return ;
}
setLocation ( JSON . parse ( userLocation ));
setIsLocationSaved ( true );
}, []);
useEffect (() => {
if ( isLocationSaved ) return ;
const getLocationData = async () => {
const { data } = await axios . get ( `https://ipinfo.io/json` );
const locationData = {
country_code: data . country ,
country_name: new Intl . DisplayNames ([ "en" ], { type: "region" })
. of ( data . country ),
};
setLocation ( locationData );
localStorage . setItem ( USER_LOCATION , JSON . stringify ( locationData ));
setIsLocationSaved ( true );
};
getLocationData ();
}, [ isLocationSaved ]);
return children ;
}
Location is stored in localStorage to avoid repeated API calls and persists across sessions.
Profile Components
Components for displaying user’s watchlist, favorites, and ratings.
TileList Grid display for user’s films
Sort Sort options for user lists
User Profile header with stats
TileList Usage
< TileList
films = { userWatchlist }
type = "watchlist"
sort = "date_added"
/>
Authentication Flow
User clicks LoginButton
Redirected to /login?redirect_to=/current-page
User logs in with TMDB
OAuth flow with TMDB authentication
Session created
Server creates session and stores user data
UserProvider fetches user
useAuth hook fetches user data and updates userStore
UI updates
LoginButton replaced with LogoutButton/avatar
Key Files
User Components
State Management
src/components/
├── User/
│ ├── LoginButton.jsx
│ ├── LogoutButton.jsx
│ ├── Location.jsx
│ ├── Actions/
│ │ ├── FavoriteButton.jsx
│ │ ├── WatchlistButton.jsx
│ │ └── UserRating.jsx
│ └── Profile/
│ ├── User.jsx
│ ├── TileList.jsx
│ └── Sort.jsx
├── Auth/
│ └── LoginForm.jsx
└── Providers/
└── UserProvider.jsx
API Routes Authentication and user action endpoints
Film Components Components that use user actions