feat(plex-auth): enhanced layout and styling for better UX

This commit is contained in:
0xsysr3ll
2025-07-21 23:57:02 +02:00
parent c6ab5c56ad
commit 718c64f973
3 changed files with 176 additions and 56 deletions

View File

@@ -1,5 +1,7 @@
import Button from '@app/components/Common/Button';
import defineMessages from '@app/utils/defineMessages';
import { LockClosedIcon } from '@heroicons/react/24/solid';
import Image from 'next/image';
import { useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
@@ -16,13 +18,20 @@ const messages = defineMessages('components.Login.PlexPinEntry', {
interface PlexPinEntryProps {
profileId: string;
profileName: string;
profileThumb?: string | null;
isProtected?: boolean;
isMainUser?: boolean;
error?: string | null;
onSubmit: (pin: string) => Promise<void>;
onCancel: () => void;
error?: string | null;
}
const PlexPinEntry = ({
profileName,
profileThumb,
isProtected,
isMainUser,
error,
onSubmit,
onCancel,
}: PlexPinEntryProps) => {
@@ -60,7 +69,6 @@ const PlexPinEntry = ({
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/\D/g, '');
setPin(value);
if (value.length === 4 && !isSubmitting) {
handleSubmit(value);
}
@@ -70,46 +78,115 @@ const PlexPinEntry = ({
e.target.select();
};
// PIN boxes rendering
const pinDigits = pin.split('').slice(0, 4);
const boxes = Array.from({ length: 4 }, (_, i) => (
<div
key={i}
className={`mx-2 flex h-12 w-12 items-center justify-center rounded-lg border-2 font-mono text-2xl transition-all
${
i === pin.length
? 'border-indigo-500 ring-2 ring-indigo-500'
: 'border-white/30'
}
${pinDigits[i] ? 'text-white' : 'text-white/40'}`}
aria-label={pinDigits[i] ? 'Entered' : 'Empty'}
>
{pinDigits[i] ? '•' : ''}
</div>
));
return (
<div className="w-full max-w-md">
<h2 className="mb-6 text-center text-xl font-bold text-gray-100">
{intl.formatMessage(messages.pinRequired)}
</h2>
<p className="mb-6 text-center text-sm text-gray-300">
{intl.formatMessage(messages.pinDescription)}{' '}
<strong>{profileName}</strong>
</p>
<div className="mb-6">
<input
ref={inputRef}
type="password"
className="w-full rounded-md bg-white/10 px-4 py-3 text-center font-mono text-3xl tracking-[0.3em] text-white placeholder-white/30 focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={pin}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
placeholder="• • • •"
maxLength={4}
pattern="[0-9]{4}"
inputMode="numeric"
/>
</div>
<div className="flex justify-between">
<Button buttonType="default" onClick={onCancel} className="mr-2 flex-1">
{intl.formatMessage(messages.cancel)}
</Button>
<Button
buttonType="primary"
disabled={!pin || isSubmitting}
onClick={() => handleSubmit()}
className="ml-2 flex-1"
>
{isSubmitting
? intl.formatMessage(messages.pinCheck)
: intl.formatMessage(messages.submit)}
</Button>
<div className="mx-auto flex w-full max-w-md flex-col items-center rounded-2xl border border-white/20 bg-white/10 p-6 shadow-lg backdrop-blur">
<div className="flex w-full flex-col items-center">
{/* Avatar */}
<div className="relative mx-auto mb-1 flex h-20 w-20 shrink-0 grow-0 items-center justify-center overflow-hidden rounded-full bg-gray-900 shadow ring-2 ring-indigo-400">
{profileThumb ? (
<Image
src={profileThumb}
alt={profileName}
fill
sizes="80px"
className="object-cover"
/>
) : (
<span className="flex h-16 w-16 items-center justify-center rounded-full bg-gray-700 text-3xl font-bold text-white">
{profileName?.[0] || '?'}
</span>
)}
</div>
{/* Icons */}
<div className="mb-1 flex items-center justify-center gap-2">
{isProtected && (
<span className="z-10 rounded-full bg-black/80 p-1.5">
<LockClosedIcon className="h-4 w-4 text-indigo-400" />
</span>
)}
{isMainUser && (
<span className="z-10 rounded-full bg-black/80 p-1.5">
<svg
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4 text-yellow-400"
>
<path d="M2.166 6.5l3.5 7 4.334-7 4.334 7 3.5-7L17.5 17.5h-15z" />
</svg>
</span>
)}
</div>
<p className="mb-3 text-center text-base font-semibold text-white">
{profileName}
</p>
<h2 className="mb-3 text-center text-xl font-bold text-white">
{intl.formatMessage(messages.pinRequired)}
</h2>
<p className="mb-4 text-center text-sm text-gray-200">
{intl.formatMessage(messages.pinDescription)}
</p>
<div className="mb-4 flex flex-row items-center justify-center">
{boxes}
{/* Visually hidden input for keyboard entry */}
<input
ref={inputRef}
type="password"
className="absolute opacity-0"
value={pin}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
maxLength={4}
pattern="[0-9]{4}"
inputMode="numeric"
aria-label="PIN Input"
/>
</div>
{error && (
<div
className="mb-4 text-center font-medium text-red-400"
aria-live="polite"
>
{error}
</div>
)}
<div className="flex w-full justify-between">
<Button
buttonType="default"
onClick={onCancel}
className="mr-2 flex-1"
>
{intl.formatMessage(messages.cancel)}
</Button>
<Button
buttonType="primary"
disabled={!pin || isSubmitting}
onClick={() => handleSubmit()}
className="ml-2 flex-1"
>
{isSubmitting
? intl.formatMessage(messages.pinCheck)
: intl.formatMessage(messages.submit)}
</Button>
</div>
</div>
</div>
);

View File

@@ -35,7 +35,6 @@ const PlexProfileSelector = ({
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pinError, setPinError] = useState<string | null>(null);
const [showPinEntry, setShowPinEntry] = useState(false);
const [selectedProfile, setSelectedProfile] = useState<PlexProfile | null>(
null
@@ -62,14 +61,13 @@ const PlexProfileSelector = ({
const handlePinSubmit = async (pin: string) => {
if (!selectedProfileId) return;
await onProfileSelected(selectedProfileId, pin, setPinError);
await onProfileSelected(selectedProfileId, pin);
};
const handlePinCancel = () => {
setShowPinEntry(false);
setSelectedProfile(null);
setSelectedProfileId(null);
setPinError(null);
};
if (showPinEntry && selectedProfile && selectedProfileId) {
@@ -81,9 +79,11 @@ const PlexProfileSelector = ({
selectedProfile.username ||
intl.formatMessage(messages.profile)
}
profileThumb={selectedProfile.thumb}
isProtected={selectedProfile.protected}
isMainUser={selectedProfile.isMainUser}
onSubmit={handlePinSubmit}
onCancel={handlePinCancel}
error={pinError}
/>
);
}
@@ -110,7 +110,7 @@ const PlexProfileSelector = ({
</div>
)}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div className="grid grid-cols-2 justify-items-center gap-4 sm:grid-cols-3 sm:gap-6 md:gap-8">
{profiles.map((profile) => (
<button
key={profile.id}
@@ -120,13 +120,13 @@ const PlexProfileSelector = ({
isSubmitting ||
(selectedProfileId === profile.id && !profile.protected)
}
className={`relative flex transform flex-col items-center rounded-2xl p-5 transition-all hover:scale-105 hover:shadow-lg ${
className={`relative flex h-48 w-32 flex-col items-center justify-start rounded-2xl border border-white/20 bg-white/10 p-6 shadow-lg backdrop-blur transition-all hover:ring-2 hover:ring-indigo-400 ${
selectedProfileId === profile.id
? 'bg-indigo-600 ring-2 ring-indigo-400'
: 'border border-white/20 bg-white/10 backdrop-blur-sm'
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : ''}`}
>
<div className="relative mb-4 h-20 w-20 overflow-hidden rounded-full shadow-md ring-2 ring-white/30">
<div className="relative mx-auto mb-2 flex h-20 w-20 shrink-0 grow-0 items-center justify-center overflow-hidden rounded-full bg-gray-900 shadow ring-2 ring-indigo-400">
<Image
src={profile.thumb}
alt={profile.title || profile.username || 'Profile'}
@@ -135,19 +135,30 @@ const PlexProfileSelector = ({
className="object-cover"
/>
</div>
<div className="mb-2 flex items-center justify-center gap-2">
{profile.protected && (
<span className="z-10 rounded-full bg-black/80 p-1.5">
<LockClosedIcon className="h-4 w-4 text-indigo-400" />
</span>
)}
{profile.isMainUser && (
<span className="z-10 rounded-full bg-black/80 p-1.5">
<svg
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4 text-yellow-400"
>
<path d="M2.166 6.5l3.5 7 4.334-7 4.334 7 3.5-7L17.5 17.5h-15z" />
</svg>
</span>
)}
</div>
<span
className="mb-1 w-full break-words text-center text-base font-semibold text-white"
title={profile.username || profile.title}
>
{profile.username || profile.title}
</span>
{profile.protected && (
<div className="mt-2 text-gray-400">
<LockClosedIcon className="h-4 w-4" />
</div>
)}
</button>
))}
</div>

View File

@@ -57,6 +57,9 @@ const Login = () => {
const [showPinEntry, setShowPinEntry] = useState(false);
const [pinProfileId, setPinProfileId] = useState<string | null>(null);
const [pinProfileName, setPinProfileName] = useState<string | null>(null);
const [pinProfileThumb, setPinProfileThumb] = useState<string | null>(null);
const [pinIsProtected, setPinIsProtected] = useState<boolean>(false);
const [pinIsMainUser, setPinIsMainUser] = useState<boolean>(false);
const [pinError, setPinError] = useState<string | null>(null);
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
@@ -69,6 +72,9 @@ const Login = () => {
case 'REQUIRES_PIN': {
setPinProfileId(response.data.profileId);
setPinProfileName(response.data.profileName);
setPinProfileThumb(response.data.profileThumb);
setPinIsProtected(response.data.isProtected);
setPinIsMainUser(response.data.isMainUser);
setShowPinEntry(true);
break;
}
@@ -131,12 +137,32 @@ const Login = () => {
if (response.data?.status === 'REQUIRES_PIN') {
setShowPinEntry(true);
setPinProfileId(profileId);
setPinProfileName(
profiles.find((p) => p.id === profileId)?.title ||
profiles.find((p) => p.id === profileId)?.username ||
'Profile'
);
setPinProfileThumb(
profiles.find((p) => p.id === profileId)?.thumb || null
);
setPinIsProtected(
profiles.find((p) => p.id === profileId)?.protected || false
);
setPinIsMainUser(
profiles.find((p) => p.id === profileId)?.isMainUser || false
);
setPinError(intl.formatMessage(messages.invalidPin));
throw new Error('Invalid PIN');
} else {
setShowProfileSelector(false);
setShowPinEntry(false);
setPinError(null);
setPinProfileId(null);
setPinProfileName(null);
setPinProfileThumb(null);
setPinIsProtected(false);
setPinIsMainUser(false);
revalidate();
}
} catch (e) {
@@ -301,6 +327,10 @@ const Login = () => {
<PlexPinEntry
profileId={pinProfileId}
profileName={pinProfileName}
profileThumb={pinProfileThumb}
isProtected={pinIsProtected}
isMainUser={pinIsMainUser}
error={pinError}
onSubmit={(pin) => {
return handleSubmitProfile(pinProfileId, pin);
}}
@@ -308,10 +338,12 @@ const Login = () => {
setShowPinEntry(false);
setPinProfileId(null);
setPinProfileName(null);
setPinProfileThumb(null);
setPinIsProtected(false);
setPinIsMainUser(false);
setPinError(null);
setShowProfileSelector(true);
}}
error={pinError}
/>
) : showProfileSelector ? (
<PlexProfileSelector