feat(plex-auth): enhanced layout and styling for better UX
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user