mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
password redesign
This commit is contained in:
1 parent
a39c28d807
commit
97a17e516d
6 files changed
+159
-131
No files matched your search
@@ -248,6 +248,15 @@ const resources = {
|
|||||||
verifyFailed: 'Verification failed',
|
verifyFailed: 'Verification failed',
|
||||||
resendFailed: 'Failed to resend code',
|
resendFailed: 'Failed to resend code',
|
||||||
genericError: 'An error occurred. Please try again later.',
|
genericError: 'An error occurred. Please try again later.',
|
||||||
|
showPassword: 'Show password',
|
||||||
|
hidePassword: 'Hide password',
|
||||||
|
passwordTooShort: 'Password must be at least 8 characters.',
|
||||||
|
passwordNumericOnly: 'Password cannot be entirely numeric.',
|
||||||
|
passwordStrength: 'Strength',
|
||||||
|
strengthWeak: 'Weak',
|
||||||
|
strengthFair: 'Fair',
|
||||||
|
strengthGood: 'Good',
|
||||||
|
strengthStrong: 'Strong',
|
||||||
},
|
},
|
||||||
community: {
|
community: {
|
||||||
title: 'Community Activity',
|
title: 'Community Activity',
|
||||||
@@ -633,6 +642,15 @@ const resources = {
|
|||||||
verifyFailed: 'Verifizierung fehlgeschlagen',
|
verifyFailed: 'Verifizierung fehlgeschlagen',
|
||||||
resendFailed: 'Code konnte nicht erneut gesendet werden',
|
resendFailed: 'Code konnte nicht erneut gesendet werden',
|
||||||
genericError: 'Ein Fehler ist aufgetreten. Bitte versuche es später erneut.',
|
genericError: 'Ein Fehler ist aufgetreten. Bitte versuche es später erneut.',
|
||||||
|
showPassword: 'Passwort anzeigen',
|
||||||
|
hidePassword: 'Passwort verbergen',
|
||||||
|
passwordTooShort: 'Passwort muss mindestens 8 Zeichen lang sein.',
|
||||||
|
passwordNumericOnly: 'Passwort darf nicht nur aus Zahlen bestehen.',
|
||||||
|
passwordStrength: 'Stärke',
|
||||||
|
strengthWeak: 'Schwach',
|
||||||
|
strengthFair: 'Mittel',
|
||||||
|
strengthGood: 'Gut',
|
||||||
|
strengthStrong: 'Stark',
|
||||||
},
|
},
|
||||||
community: {
|
community: {
|
||||||
title: 'Community-Aktivität',
|
title: 'Community-Aktivität',
|
||||||
|
|||||||
@@ -9,29 +9,22 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Grid,
|
Grid,
|
||||||
List,
|
List,
|
||||||
ListItemAvatar,
|
|
||||||
ListItemText,
|
ListItemText,
|
||||||
ListItemButton,
|
ListItemButton,
|
||||||
Pagination,
|
Pagination,
|
||||||
TextField,
|
|
||||||
IconButton,
|
IconButton,
|
||||||
Collapse,
|
Collapse,
|
||||||
Link as MuiLink,
|
Link as MuiLink,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
|
||||||
import CommentIcon from '@mui/icons-material/Comment';
|
import CommentIcon from '@mui/icons-material/Comment';
|
||||||
import SendIcon from '@mui/icons-material/Send';
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
import WhatshotIcon from '@mui/icons-material/Whatshot';
|
import WhatshotIcon from '@mui/icons-material/Whatshot';
|
||||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
|
||||||
import PeopleIcon from '@mui/icons-material/People';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
useCommunityFeed,
|
useCommunityFeed,
|
||||||
useFollowedTopFlavors,
|
useFollowedTopFlavors,
|
||||||
useFollowingList,
|
|
||||||
type FeedRating,
|
type FeedRating,
|
||||||
} from '../api/queries/useCommunityFeed';
|
} from '../api/queries/useCommunityFeed';
|
||||||
import { useNotificationsQuery } from '../api/queries/useNotifications';
|
|
||||||
import { useCreateReply } from '../api/mutations/useRatingMutations';
|
import { useCreateReply } from '../api/mutations/useRatingMutations';
|
||||||
import { useTitle } from '../hooks/useTitle';
|
import { useTitle } from '../hooks/useTitle';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
@@ -81,27 +74,19 @@ const CommunityFeed: React.FC = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { notify } = useToast();
|
const { notify } = useToast();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [followingSearch, setFollowingSearch] = useState('');
|
|
||||||
const [replyInputs, setReplyInputs] = useState<Record<number, string>>({});
|
const [replyInputs, setReplyInputs] = useState<Record<number, string>>({});
|
||||||
const [expandedReplies, setExpandedReplies] = useState<Record<number, boolean>>({});
|
const [expandedReplies, setExpandedReplies] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isAuthed = !!user;
|
const isAuthed = !!user;
|
||||||
const { data: feed, isLoading: feedLoading, error: feedError } = useCommunityFeed(page);
|
const { data: feed, isLoading: feedLoading, error: feedError } = useCommunityFeed(page);
|
||||||
const { data: following = [] } = useFollowingList();
|
|
||||||
const { data: notificationsAll = [] } = useNotificationsQuery(isAuthed);
|
|
||||||
const { data: topFollowed = [] } = useFollowedTopFlavors();
|
const { data: topFollowed = [] } = useFollowedTopFlavors();
|
||||||
const createReply = useCreateReply();
|
const createReply = useCreateReply();
|
||||||
|
|
||||||
const ratings: FeedRating[] = feed?.ratings ?? [];
|
const ratings: FeedRating[] = feed?.ratings ?? [];
|
||||||
const totalPages = feed?.totalPages ?? 1;
|
const totalPages = feed?.totalPages ?? 1;
|
||||||
const notifications = notificationsAll.slice(0, 5);
|
|
||||||
const loading = feedLoading;
|
const loading = feedLoading;
|
||||||
|
|
||||||
const filteredFollowing = following.filter((user) =>
|
|
||||||
user.username.toLowerCase().includes(followingSearch.toLowerCase()),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthed) navigate('/login');
|
if (!isAuthed) navigate('/login');
|
||||||
}, [isAuthed, navigate]);
|
}, [isAuthed, navigate]);
|
||||||
@@ -322,12 +307,14 @@ const CommunityFeed: React.FC = () => {
|
|||||||
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
|
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
|
||||||
<MentionTextField
|
<MentionTextField
|
||||||
placeholder={t('community.writeReply')}
|
placeholder={t('community.writeReply')}
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
value={replyInputs[rating.id] || ''}
|
value={replyInputs[rating.id] || ''}
|
||||||
onChange={(val) =>
|
onChange={(val) =>
|
||||||
setReplyInputs((prev) => ({ ...prev, [rating.id]: val }))
|
setReplyInputs((prev) => ({ ...prev, [rating.id]: val }))
|
||||||
}
|
}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||||
handleReplySubmit(rating.id);
|
handleReplySubmit(rating.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -361,98 +348,6 @@ const CommunityFeed: React.FC = () => {
|
|||||||
|
|
||||||
<Grid size={{ xs: 12, lg: 4 }}>
|
<Grid size={{ xs: 12, lg: 4 }}>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
<SidebarCard
|
|
||||||
icon={<NotificationsIcon color="primary" fontSize="small" />}
|
|
||||||
title={t('community.notifications')}
|
|
||||||
>
|
|
||||||
<List disablePadding>
|
|
||||||
{notifications.length === 0 ? (
|
|
||||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{t('community.noNotifications')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
notifications.map((n) => {
|
|
||||||
const handleClick = () => {
|
|
||||||
if (n.notification_type === 'follow') {
|
|
||||||
navigate(`/profile/${n.actor_username}`);
|
|
||||||
} else if (n.flavor_id) {
|
|
||||||
navigate(`/flavor/${n.flavor_id}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<ListItemButton key={n.id} onClick={handleClick} sx={{ py: 1.5 }}>
|
|
||||||
<ListItemAvatar sx={{ minWidth: 40 }}>
|
|
||||||
<Avatar
|
|
||||||
src={n.actor_avatar || undefined}
|
|
||||||
sx={{ width: 30, height: 30, fontSize: '0.8rem' }}
|
|
||||||
>
|
|
||||||
{n.actor_username.charAt(0)}
|
|
||||||
</Avatar>
|
|
||||||
</ListItemAvatar>
|
|
||||||
<ListItemText
|
|
||||||
primary={
|
|
||||||
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
|
|
||||||
<strong>{n.actor_username}</strong>{' '}
|
|
||||||
{n.notification_type === 'reply'
|
|
||||||
? t('community.notifReply')
|
|
||||||
: n.notification_type === 'mention'
|
|
||||||
? t('community.notifMention')
|
|
||||||
: n.notification_type === 'follow'
|
|
||||||
? t('community.notifFollow')
|
|
||||||
: n.notification_type}
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
secondary={formatDate(n.created_at)}
|
|
||||||
secondaryTypographyProps={{ sx: { fontSize: '0.7rem' } }}
|
|
||||||
/>
|
|
||||||
</ListItemButton>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
</SidebarCard>
|
|
||||||
|
|
||||||
<SidebarCard
|
|
||||||
icon={<PeopleIcon color="primary" fontSize="small" />}
|
|
||||||
title={t('nav.following')}
|
|
||||||
>
|
|
||||||
<Box sx={{ p: 1.5 }}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
placeholder={t('community.searchFriends')}
|
|
||||||
value={followingSearch}
|
|
||||||
onChange={(e) => setFollowingSearch(e.target.value)}
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
startAdornment: (
|
|
||||||
<SearchIcon fontSize="small" sx={{ mr: 1, color: 'text.secondary' }} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 1 } }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<List disablePadding sx={{ maxHeight: 250, overflowY: 'auto' }}>
|
|
||||||
{filteredFollowing.map((user) => (
|
|
||||||
<ListItemButton key={user.id} component={Link} to={`/profile/${user.username}`}>
|
|
||||||
<Avatar
|
|
||||||
src={user.avatar || undefined}
|
|
||||||
sx={{ width: 28, height: 28, mr: 2, fontSize: '0.8rem' }}
|
|
||||||
>
|
|
||||||
{user.username.charAt(0)}
|
|
||||||
</Avatar>
|
|
||||||
<ListItemText
|
|
||||||
primary={user.username}
|
|
||||||
primaryTypographyProps={{ variant: 'body2', fontWeight: 'bold' }}
|
|
||||||
/>
|
|
||||||
</ListItemButton>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</SidebarCard>
|
|
||||||
|
|
||||||
<SidebarCard
|
<SidebarCard
|
||||||
icon={<WhatshotIcon color="error" fontSize="small" />}
|
icon={<WhatshotIcon color="error" fontSize="small" />}
|
||||||
title={t('community.topRated')}
|
title={t('community.topRated')}
|
||||||
|
|||||||
+125
-18
@@ -1,5 +1,19 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Typography, TextField, Button, Tab, Tabs, Alert, Stack, Box } from '@mui/material';
|
import {
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Tab,
|
||||||
|
Tabs,
|
||||||
|
Alert,
|
||||||
|
Stack,
|
||||||
|
Box,
|
||||||
|
IconButton,
|
||||||
|
InputAdornment,
|
||||||
|
LinearProgress,
|
||||||
|
} from '@mui/material';
|
||||||
|
import Visibility from '@mui/icons-material/Visibility';
|
||||||
|
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import api from '../lib/api';
|
import api from '../lib/api';
|
||||||
import { useTitle } from '../hooks/useTitle';
|
import { useTitle } from '../hooks/useTitle';
|
||||||
@@ -13,6 +27,93 @@ interface ApiError {
|
|||||||
|
|
||||||
const readError = (err: unknown): ApiError => err as ApiError;
|
const readError = (err: unknown): ApiError => err as ApiError;
|
||||||
|
|
||||||
|
// Mirror of the backend password validators (password_policy.rs): min 8 chars,
|
||||||
|
// not entirely numeric. Returns an i18n key for the first failure, or null.
|
||||||
|
const validatePassword = (pw: string): string | null => {
|
||||||
|
if (pw.length === 0) return null;
|
||||||
|
if (pw.length < 8) return 'auth.passwordTooShort';
|
||||||
|
if (/^\d+$/.test(pw)) return 'auth.passwordNumericOnly';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Heuristic 0–4 strength score (length + character-class variety).
|
||||||
|
const scorePassword = (pw: string): number => {
|
||||||
|
let score = 0;
|
||||||
|
if (pw.length >= 8) score++;
|
||||||
|
if (pw.length >= 12) score++;
|
||||||
|
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++;
|
||||||
|
if (/\d/.test(pw)) score++;
|
||||||
|
if (/[^A-Za-z0-9]/.test(pw)) score++;
|
||||||
|
return Math.min(score, 4);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PasswordStrengthMeter: React.FC<{ password: string }> = ({ password }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const score = scorePassword(password);
|
||||||
|
const levels = [
|
||||||
|
{ label: t('auth.strengthWeak'), color: 'error.main' },
|
||||||
|
{ label: t('auth.strengthWeak'), color: 'error.main' },
|
||||||
|
{ label: t('auth.strengthFair'), color: 'warning.main' },
|
||||||
|
{ label: t('auth.strengthGood'), color: 'info.main' },
|
||||||
|
{ label: t('auth.strengthStrong'), color: 'success.main' },
|
||||||
|
];
|
||||||
|
const level = levels[score];
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={(score / 4) * 100}
|
||||||
|
sx={{ height: 6, borderRadius: 3, '& .MuiLinearProgress-bar': { bgcolor: level.color } }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ color: level.color, fontWeight: 600 }}>
|
||||||
|
{t('auth.passwordStrength')}: {level.label}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PasswordField: React.FC<{
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
error?: boolean;
|
||||||
|
helperText?: string;
|
||||||
|
showStrength?: boolean;
|
||||||
|
}> = ({ label, value, onChange, error, helperText, showStrength }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={label}
|
||||||
|
type={show ? 'text' : 'password'}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
error={error}
|
||||||
|
helperText={helperText}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setShow((s) => !s)}
|
||||||
|
edge="end"
|
||||||
|
size="small"
|
||||||
|
aria-label={show ? t('auth.hidePassword') : t('auth.showPassword')}
|
||||||
|
>
|
||||||
|
{show ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showStrength && value.length > 0 && <PasswordStrengthMeter password={value} />}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const Login: React.FC = () => {
|
const Login: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -185,6 +286,7 @@ const Login: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isResetting) {
|
if (isResetting) {
|
||||||
|
const resetPwError = validatePassword(password);
|
||||||
return (
|
return (
|
||||||
<FormCard
|
<FormCard
|
||||||
title={t('auth.resetTitle')}
|
title={t('auth.resetTitle')}
|
||||||
@@ -200,7 +302,11 @@ const Login: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{t('auth.backToLogin')}
|
{t('auth.backToLogin')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="contained" type="submit">
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
disabled={resetStep === 2 && (password.length === 0 || !!resetPwError)}
|
||||||
|
>
|
||||||
{resetStep === 1 ? t('auth.sendResetCode') : t('auth.resetButton')}
|
{resetStep === 1 ? t('auth.sendResetCode') : t('auth.resetButton')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -221,12 +327,13 @@ const Login: React.FC = () => {
|
|||||||
value={verificationCode}
|
value={verificationCode}
|
||||||
onChange={(e) => setVerificationCode(e.target.value)}
|
onChange={(e) => setVerificationCode(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<PasswordField
|
||||||
fullWidth
|
|
||||||
label={t('auth.newPasswordLabel')}
|
label={t('auth.newPasswordLabel')}
|
||||||
type="password"
|
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={setPassword}
|
||||||
|
error={!!resetPwError}
|
||||||
|
helperText={resetPwError ? t(resetPwError) : undefined}
|
||||||
|
showStrength
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -263,23 +370,22 @@ const Login: React.FC = () => {
|
|||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<PasswordField label={t('auth.passwordLabel')} value={password} onChange={setPassword} />
|
||||||
fullWidth
|
|
||||||
label={t('auth.passwordLabel')}
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</FormCard>
|
</FormCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const signupPwError = validatePassword(password);
|
||||||
return (
|
return (
|
||||||
<FormCard
|
<FormCard
|
||||||
title={t('auth.signupTitle')}
|
title={t('auth.signupTitle')}
|
||||||
onSubmit={handleSignup}
|
onSubmit={handleSignup}
|
||||||
actions={
|
actions={
|
||||||
<Button variant="contained" type="submit">
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
disabled={password.length === 0 || !!signupPwError}
|
||||||
|
>
|
||||||
{t('auth.signupTitle')}
|
{t('auth.signupTitle')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -297,12 +403,13 @@ const Login: React.FC = () => {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<PasswordField
|
||||||
fullWidth
|
|
||||||
label={t('auth.passwordLabel')}
|
label={t('auth.passwordLabel')}
|
||||||
type="password"
|
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={setPassword}
|
||||||
|
error={!!signupPwError}
|
||||||
|
helperText={signupPwError ? t(signupPwError) : undefined}
|
||||||
|
showStrength
|
||||||
/>
|
/>
|
||||||
</FormCard>
|
</FormCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -287,9 +287,13 @@ const MyReviewCard: React.FC<MyReviewCardProps> = ({ rating, currentUsername })
|
|||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
<MentionTextField
|
<MentionTextField
|
||||||
placeholder={t('community.writeReply')}
|
placeholder={t('community.writeReply')}
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
value={replyInput}
|
value={replyInput}
|
||||||
onChange={setReplyInput}
|
onChange={setReplyInput}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleReplySubmit()}
|
onKeyDown={(e) =>
|
||||||
|
(e.metaKey || e.ctrlKey) && e.key === 'Enter' && handleReplySubmit()
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -572,12 +572,16 @@ const FlavorDetail: React.FC = () => {
|
|||||||
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
|
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
|
||||||
<MentionTextField
|
<MentionTextField
|
||||||
placeholder={t('community.writeReply')}
|
placeholder={t('community.writeReply')}
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
value={replyInputs[rating.id] || ''}
|
value={replyInputs[rating.id] || ''}
|
||||||
onChange={(val) =>
|
onChange={(val) =>
|
||||||
setReplyInputs({ ...replyInputs, [rating.id]: val })
|
setReplyInputs({ ...replyInputs, [rating.id]: val })
|
||||||
}
|
}
|
||||||
onKeyDown={(e) =>
|
onKeyDown={(e) =>
|
||||||
e.key === 'Enter' && !e.shiftKey && handleReplySubmit(rating.id)
|
(e.metaKey || e.ctrlKey) &&
|
||||||
|
e.key === 'Enter' &&
|
||||||
|
handleReplySubmit(rating.id)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ Tracking doc from the frontend/UX audit. Items grouped by priority. Check off as
|
|||||||
|
|
||||||
- [x] **#5 Hall of Fame carousel** (`MainPage.tsx`) — added 6s auto-advance (stops on manual control, pauses on hover, off for reduced-motion), touch swipe, on-card overlay arrows on mobile (header arrows desktop-only), and featured quote now picks the highest-scored comment instead of the first.
|
- [x] **#5 Hall of Fame carousel** (`MainPage.tsx`) — added 6s auto-advance (stops on manual control, pauses on hover, off for reduced-motion), touch swipe, on-card overlay arrows on mobile (header arrows desktop-only), and featured quote now picks the highest-scored comment instead of the first.
|
||||||
- [x] **#6 Admin edit leaks into product card** (`FlavorDetail.tsx`) — extracted to `components/AdminFlavorEditDialog.tsx`; card keeps a clean read view, edit opens a focused dialog.
|
- [x] **#6 Admin edit leaks into product card** (`FlavorDetail.tsx`) — extracted to `components/AdminFlavorEditDialog.tsx`; card keeps a clean read view, edit opens a focused dialog.
|
||||||
- [ ] **#7 CommunityFeed duplication** — in-page notifications + following duplicate the nav/global menus.
|
- [x] **#7 CommunityFeed duplication** (`CommunityFeed.tsx`) — dropped the in-page Notifications + Following sidebar cards (NavSidebar already provides both: NotificationMenu popup + Following collapse). Kept the unique "Circle's Top Rated" card. Removed now-dead queries/state/imports.
|
||||||
- [ ] **#8 Reply Enter-to-submit on multiline** — surprising; blocks newlines.
|
- [x] **#8 Reply Enter-to-submit on multiline** (`CommunityFeed.tsx`, `FlavorDetail.tsx`, `MyReviewCard.tsx`) — reply fields are now `multiline rows={2}`; plain Enter inserts a newline, submit moved to Ctrl/Cmd+Enter (no longer hijacks Enter, no longer blocks newlines).
|
||||||
- [ ] **#9 Login password UX** — no show/hide toggle, no strength hint, no inline validation.
|
- [x] **#9 Login password UX** (`Login.tsx`) — added a `PasswordField` with show/hide toggle (all password inputs), a 0–4 strength meter on signup + reset-new-password, and inline validation mirroring the backend (min 8 chars, not entirely numeric) that gates the submit button.
|
||||||
|
|
||||||
## 🟢 Design-system cleanup
|
## 🟢 Design-system cleanup
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user