migrated alerts and community

This commit is contained in:
nvrl committed 2026-04-24 12:20:54 +02:00
1 parent 607f8f9bc1
commit c42fd733f0
16 files changed
+274 -58

No files matched your search

+7 -1
View File
@@ -2,12 +2,18 @@ import type { ReactNode } from 'react';
import { ThemeProvider } from '../contexts/ThemeContext'; import { ThemeProvider } from '../contexts/ThemeContext';
import { AuthProvider } from '../contexts/AuthContext'; import { AuthProvider } from '../contexts/AuthContext';
import { NotificationProvider } from '../contexts/NotificationContext'; import { NotificationProvider } from '../contexts/NotificationContext';
import { ToastProvider } from '../contexts/ToastContext';
import { ConfirmProvider } from '../contexts/ConfirmContext';
import '../i18n'; import '../i18n';
export const AppProviders = ({ children }: { children: ReactNode }) => ( export const AppProviders = ({ children }: { children: ReactNode }) => (
<ThemeProvider> <ThemeProvider>
<AuthProvider> <AuthProvider>
<NotificationProvider>{children}</NotificationProvider> <NotificationProvider>
<ToastProvider>
<ConfirmProvider>{children}</ConfirmProvider>
</ToastProvider>
</NotificationProvider>
</AuthProvider> </AuthProvider>
</ThemeProvider> </ThemeProvider>
); );
+74
View File
@@ -0,0 +1,74 @@
import { createContext, useCallback, useRef, useState, type ReactNode } from 'react';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
export interface ConfirmOptions {
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
}
export interface ConfirmContextValue {
confirm: (opts: ConfirmOptions | string) => Promise<boolean>;
}
export const ConfirmContext = createContext<ConfirmContextValue | null>(null);
export const ConfirmProvider = ({ children }: { children: ReactNode }) => {
const [open, setOpen] = useState(false);
const [opts, setOpts] = useState<ConfirmOptions | null>(null);
const resolverRef = useRef<((value: boolean) => void) | null>(null);
const confirm = useCallback((input: ConfirmOptions | string) => {
const normalized: ConfirmOptions =
typeof input === 'string' ? { message: input } : input;
setOpts(normalized);
setOpen(true);
return new Promise<boolean>((resolve) => {
resolverRef.current = resolve;
});
}, []);
const handle = (result: boolean) => {
setOpen(false);
resolverRef.current?.(result);
resolverRef.current = null;
};
return (
<ConfirmContext.Provider value={{ confirm }}>
{children}
<Dialog
open={open}
onClose={() => handle(false)}
maxWidth="xs"
fullWidth
PaperProps={{ sx: { borderRadius: 3 } }}
>
{opts?.title && <DialogTitle sx={{ fontWeight: 'bold' }}>{opts.title}</DialogTitle>}
<DialogContent>
<DialogContentText>{opts?.message}</DialogContentText>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => handle(false)} variant="outlined">
{opts?.cancelLabel ?? 'Cancel'}
</Button>
<Button
onClick={() => handle(true)}
variant="contained"
color={opts?.danger ? 'error' : 'primary'}
autoFocus
>
{opts?.confirmLabel ?? 'Confirm'}
</Button>
</DialogActions>
</Dialog>
</ConfirmContext.Provider>
);
};
+63
View File
@@ -0,0 +1,63 @@
import { createContext, useCallback, useState, type ReactNode } from 'react';
import Snackbar from '@mui/material/Snackbar';
import Alert, { type AlertColor } from '@mui/material/Alert';
export interface ToastOptions {
message: string;
severity?: AlertColor;
duration?: number;
}
export interface ToastContextValue {
notify: (opts: ToastOptions | string) => void;
}
export const ToastContext = createContext<ToastContextValue | null>(null);
interface ToastState extends ToastOptions {
key: number;
}
export const ToastProvider = ({ children }: { children: ReactNode }) => {
const [toast, setToast] = useState<ToastState | null>(null);
const notify = useCallback((opts: ToastOptions | string) => {
const normalized: ToastOptions =
typeof opts === 'string' ? { message: opts, severity: 'info' } : opts;
setToast({
message: normalized.message,
severity: normalized.severity ?? 'info',
duration: normalized.duration ?? 4000,
key: Date.now(),
});
}, []);
const handleClose = (_: unknown, reason?: string) => {
if (reason === 'clickaway') return;
setToast(null);
};
return (
<ToastContext.Provider value={{ notify }}>
{children}
<Snackbar
key={toast?.key}
open={!!toast}
autoHideDuration={toast?.duration}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
{toast ? (
<Alert
onClose={() => setToast(null)}
severity={toast.severity}
variant="filled"
sx={{ width: '100%' }}
>
{toast.message}
</Alert>
) : undefined}
</Snackbar>
</ToastContext.Provider>
);
};
+8
View File
@@ -0,0 +1,8 @@
import { useContext } from 'react';
import { ConfirmContext } from '../contexts/ConfirmContext';
export const useConfirm = () => {
const ctx = useContext(ConfirmContext);
if (!ctx) throw new Error('useConfirm must be used within ConfirmProvider');
return ctx;
};
+8
View File
@@ -0,0 +1,8 @@
import { useContext } from 'react';
import { ToastContext } from '../contexts/ToastContext';
export const useToast = () => {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within ToastProvider');
return ctx;
};
+14 -6
View File
@@ -31,6 +31,7 @@ import {
FormControl, FormControl,
} from '@mui/material'; } from '@mui/material';
import { PageShell, GlassCard, GlassPaper, EmptyState } from '../components/ui'; import { PageShell, GlassCard, GlassPaper, EmptyState } from '../components/ui';
import { useToast } from '../hooks/useToast';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import EmailIcon from '@mui/icons-material/Email'; import EmailIcon from '@mui/icons-material/Email';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
@@ -134,6 +135,7 @@ const AdminDashboard: React.FC = () => {
const [currentTab, setCurrentTab] = useState(0); const [currentTab, setCurrentTab] = useState(0);
const [selectedBanner, setSelectedBanner] = useState<Banner | null>(null); const [selectedBanner, setSelectedBanner] = useState<Banner | null>(null);
const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false); const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false);
const { notify } = useToast();
const fetchData = useCallback(async (silent = false) => { const fetchData = useCallback(async (silent = false) => {
if (!silent) setLoading(true); if (!silent) setLoading(true);
@@ -177,7 +179,7 @@ const AdminDashboard: React.FC = () => {
const res = await api.patch('admin-custom/config/', { [key]: value }); const res = await api.patch('admin-custom/config/', { [key]: value });
setConfig(res.data); setConfig(res.data);
} catch { } catch {
alert('Failed to update configuration'); notify({ message: 'Failed to update configuration', severity: 'error' });
} }
}; };
@@ -186,7 +188,7 @@ const AdminDashboard: React.FC = () => {
await api.post(`admin-custom/${id}/trigger_job/`); await api.post(`admin-custom/${id}/trigger_job/`);
fetchData(true); fetchData(true);
} catch { } catch {
alert('Failed to trigger job'); notify({ message: 'Failed to trigger job', severity: 'error' });
} }
}; };
@@ -197,7 +199,7 @@ const AdminDashboard: React.FC = () => {
await api.patch(`admin-custom/${id}/update_job_schedule/`, data); await api.patch(`admin-custom/${id}/update_job_schedule/`, data);
fetchData(true); fetchData(true);
} catch { } catch {
alert('Failed to update schedule'); notify({ message: 'Failed to update schedule', severity: 'error' });
} }
}; };
@@ -216,7 +218,10 @@ const AdminDashboard: React.FC = () => {
fetchData(); fetchData();
} catch (err) { } catch (err) {
const error = err as { response?: { data?: { error?: string } } }; const error = err as { response?: { data?: { error?: string } } };
alert(error.response?.data?.error || 'Failed to toggle visibility'); notify({
message: error.response?.data?.error || 'Failed to toggle visibility',
severity: 'error',
});
} }
}; };
@@ -227,10 +232,13 @@ const AdminDashboard: React.FC = () => {
const handleSendTestEmail = async () => { const handleSendTestEmail = async () => {
try { try {
await api.post('admin-custom/send_test_email/'); await api.post('admin-custom/send_test_email/');
alert(t('admin.testEmailSuccess')); notify({ message: t('admin.testEmailSuccess'), severity: 'success' });
} catch (err) { } catch (err) {
const error = err as { response?: { data?: { error?: string } } }; const error = err as { response?: { data?: { error?: string } } };
alert(`Error: ${error.response?.data?.error || 'Failed to send'}`); notify({
message: `Error: ${error.response?.data?.error || 'Failed to send'}`,
severity: 'error',
});
} }
}; };
+8 -4
View File
@@ -8,6 +8,8 @@ import api from '../lib/api';
import { useTitle } from '../hooks/useTitle'; import { useTitle } from '../hooks/useTitle';
import MentionTextField from '../components/MentionTextField'; import MentionTextField from '../components/MentionTextField';
import { PageShell, SectionHeader, FormCard } from '../components/ui'; import { PageShell, SectionHeader, FormCard } from '../components/ui';
import { useToast } from '../hooks/useToast';
import { useConfirm } from '../hooks/useConfirm';
interface AdminRating { interface AdminRating {
id: number; id: number;
@@ -25,6 +27,8 @@ const AdminRatingDetail: React.FC = () => {
const [comment, setComment] = useState(''); const [comment, setComment] = useState('');
const [rating, setRating] = useState<AdminRating | null>(null); const [rating, setRating] = useState<AdminRating | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { notify } = useToast();
const { confirm } = useConfirm();
const fetchRating = useCallback(async () => { const fetchRating = useCallback(async () => {
try { try {
@@ -51,19 +55,19 @@ const AdminRatingDetail: React.FC = () => {
try { try {
await api.patch(`ratings/${id}/`, { score, comment }); await api.patch(`ratings/${id}/`, { score, comment });
fetchRating(); fetchRating();
alert('Rating updated!'); notify({ message: 'Rating updated!', severity: 'success' });
} catch { } catch {
alert('Update failed'); notify({ message: 'Update failed', severity: 'error' });
} }
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!window.confirm('Delete this rating and all replies?')) return; if (!(await confirm({ message: 'Delete this rating and all replies?', danger: true }))) return;
try { try {
await api.delete(`ratings/${id}/`); await api.delete(`ratings/${id}/`);
navigate('/admin-panel'); navigate('/admin-panel');
} catch { } catch {
alert('Delete failed'); notify({ message: 'Delete failed', severity: 'error' });
} }
}; };
+8 -4
View File
@@ -8,6 +8,8 @@ import api from '../lib/api';
import { useTitle } from '../hooks/useTitle'; import { useTitle } from '../hooks/useTitle';
import MentionTextField from '../components/MentionTextField'; import MentionTextField from '../components/MentionTextField';
import { PageShell, SectionHeader, FormCard } from '../components/ui'; import { PageShell, SectionHeader, FormCard } from '../components/ui';
import { useToast } from '../hooks/useToast';
import { useConfirm } from '../hooks/useConfirm';
interface AdminReply { interface AdminReply {
id: number; id: number;
@@ -22,6 +24,8 @@ const AdminReplyDetail: React.FC = () => {
const [text, setText] = useState(''); const [text, setText] = useState('');
const [reply, setReply] = useState<AdminReply | null>(null); const [reply, setReply] = useState<AdminReply | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { notify } = useToast();
const { confirm } = useConfirm();
const fetchReply = useCallback(async () => { const fetchReply = useCallback(async () => {
try { try {
@@ -47,19 +51,19 @@ const AdminReplyDetail: React.FC = () => {
try { try {
await api.patch(`replies/${id}/`, { text }); await api.patch(`replies/${id}/`, { text });
fetchReply(); fetchReply();
alert('Reply updated!'); notify({ message: 'Reply updated!', severity: 'success' });
} catch { } catch {
alert('Update failed'); notify({ message: 'Update failed', severity: 'error' });
} }
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!window.confirm('Delete this reply?')) return; if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return;
try { try {
await api.delete(`replies/${id}/`); await api.delete(`replies/${id}/`);
navigate('/admin-panel'); navigate('/admin-panel');
} catch { } catch {
alert('Delete failed'); notify({ message: 'Delete failed', severity: 'error' });
} }
}; };
+20 -10
View File
@@ -37,6 +37,8 @@ import {
FormCard, FormCard,
EmptyState, EmptyState,
} from '../components/ui'; } from '../components/ui';
import { useToast } from '../hooks/useToast';
import { useConfirm } from '../hooks/useConfirm';
interface AdminReply { interface AdminReply {
id: number; id: number;
@@ -81,6 +83,9 @@ const AdminUserDetail: React.FC = () => {
const [editingReplyId, setEditingReplyId] = useState<number | null>(null); const [editingReplyId, setEditingReplyId] = useState<number | null>(null);
const [editReplyText, setEditReplyText] = useState(''); const [editReplyText, setEditReplyText] = useState('');
const { notify } = useToast();
const { confirm } = useConfirm();
const fetchUser = useCallback(async () => { const fetchUser = useCallback(async () => {
try { try {
const res = await api.get(`admin-custom/${id}/user_detail/`); const res = await api.get(`admin-custom/${id}/user_detail/`);
@@ -105,7 +110,7 @@ const AdminUserDetail: React.FC = () => {
await api.patch(`admin-custom/${id}/user_detail/`, { is_active: !user.is_active }); await api.patch(`admin-custom/${id}/user_detail/`, { is_active: !user.is_active });
fetchUser(); fetchUser();
} catch { } catch {
alert('Action failed'); notify({ message: 'Action failed', severity: 'error' });
} }
}; };
@@ -115,17 +120,17 @@ const AdminUserDetail: React.FC = () => {
setEditingRatingId(null); setEditingRatingId(null);
fetchUser(); fetchUser();
} catch { } catch {
alert('Update failed'); notify({ message: 'Update failed', severity: 'error' });
} }
}; };
const handleDeleteRating = async (ratingId: number) => { const handleDeleteRating = async (ratingId: number) => {
if (!confirm('Delete this rating?')) return; if (!(await confirm({ message: 'Delete this rating?', danger: true }))) return;
try { try {
await api.delete(`ratings/${ratingId}/`); await api.delete(`ratings/${ratingId}/`);
fetchUser(); fetchUser();
} catch { } catch {
alert('Delete failed'); notify({ message: 'Delete failed', severity: 'error' });
} }
}; };
@@ -135,28 +140,33 @@ const AdminUserDetail: React.FC = () => {
setEditingReplyId(null); setEditingReplyId(null);
fetchUser(); fetchUser();
} catch { } catch {
alert('Update failed'); notify({ message: 'Update failed', severity: 'error' });
} }
}; };
const handleDeleteReply = async (replyId: number) => { const handleDeleteReply = async (replyId: number) => {
if (!confirm('Delete this reply?')) return; if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return;
try { try {
await api.delete(`replies/${replyId}/`); await api.delete(`replies/${replyId}/`);
fetchUser(); fetchUser();
} catch { } catch {
alert('Delete failed'); notify({ message: 'Delete failed', severity: 'error' });
} }
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!window.confirm('PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.')) const ok = await confirm({
return; title: 'Delete user',
message: 'PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.',
confirmLabel: 'Delete forever',
danger: true,
});
if (!ok) return;
try { try {
await api.delete(`admin-custom/${id}/user_detail/`); await api.delete(`admin-custom/${id}/user_detail/`);
navigate('/admin-panel'); navigate('/admin-panel');
} catch { } catch {
alert('Delete failed'); notify({ message: 'Delete failed', severity: 'error' });
} }
}; };
+3 -1
View File
@@ -40,6 +40,7 @@ import {
GlassCard, GlassCard,
EmptyState, EmptyState,
} from '../components/ui'; } from '../components/ui';
import { useToast } from '../hooks/useToast';
interface Reply { interface Reply {
id: number; id: number;
@@ -129,6 +130,7 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
const { t } = useTranslation(); const { t } = useTranslation();
useTitle(t('community.title')); useTitle(t('community.title'));
const navigate = useNavigate(); const navigate = useNavigate();
const { notify } = useToast();
const [ratings, setRatings] = useState<FeedRating[]>([]); const [ratings, setRatings] = useState<FeedRating[]>([]);
const [following, setFollowing] = useState<FollowedUser[]>([]); const [following, setFollowing] = useState<FollowedUser[]>([]);
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
@@ -213,7 +215,7 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
); );
setReplyInputs((prev) => ({ ...prev, [ratingId]: '' })); setReplyInputs((prev) => ({ ...prev, [ratingId]: '' }));
} catch { } catch {
alert('Failed to send reply'); notify({ message: 'Failed to send reply', severity: 'error' });
} }
}; };
+11 -7
View File
@@ -40,6 +40,8 @@ import MentionTextField from '../components/MentionTextField';
import RichText from '../components/RichText'; import RichText from '../components/RichText';
import { formatDate } from '../utils/date'; import { formatDate } from '../utils/date';
import { PageShell, GlassCard, GlassSurface, EmptyState } from '../components/ui'; import { PageShell, GlassCard, GlassSurface, EmptyState } from '../components/ui';
import { useToast } from '../hooks/useToast';
import { useConfirm } from '../hooks/useConfirm';
interface Reply { interface Reply {
id: number; id: number;
@@ -108,12 +110,14 @@ const Dashboard: React.FC = () => {
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 { notify } = useToast();
const { confirm } = useConfirm();
const shareUrl = data ? `${window.location.origin}/profile/${data.user.username}` : ''; const shareUrl = data ? `${window.location.origin}/profile/${data.user.username}` : '';
const handleCopyLink = () => { const handleCopyLink = () => {
navigator.clipboard.writeText(shareUrl); navigator.clipboard.writeText(shareUrl);
alert(t('dashboard.copySuccess')); notify({ message: t('dashboard.copySuccess'), severity: 'success' });
}; };
const handleGoBack = () => { const handleGoBack = () => {
@@ -175,17 +179,17 @@ const Dashboard: React.FC = () => {
setEditingRatingId(null); setEditingRatingId(null);
fetchData(); fetchData();
} catch { } catch {
alert('Failed to update review'); notify({ message: 'Failed to update review', severity: 'error' });
} }
}; };
const handleDeleteRating = async (ratingId: number) => { const handleDeleteRating = async (ratingId: number) => {
if (!confirm('Delete this review?')) return; if (!(await confirm({ message: 'Delete this review?', danger: true }))) return;
try { try {
await api.delete(`ratings/${ratingId}/`); await api.delete(`ratings/${ratingId}/`);
fetchData(); fetchData();
} catch { } catch {
alert('Failed to delete review'); notify({ message: 'Failed to delete review', severity: 'error' });
} }
}; };
@@ -198,17 +202,17 @@ const Dashboard: React.FC = () => {
setExpandedReplies({ ...expandedReplies, [ratingId]: true }); setExpandedReplies({ ...expandedReplies, [ratingId]: true });
fetchData(); fetchData();
} catch { } catch {
alert('Failed to send reply'); notify({ message: 'Failed to send reply', severity: 'error' });
} }
}; };
const handleDeleteReply = async (replyId: number) => { const handleDeleteReply = async (replyId: number) => {
if (!confirm('Delete this reply?')) return; if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return;
try { try {
await api.delete(`replies/${replyId}/`); await api.delete(`replies/${replyId}/`);
fetchData(); fetchData();
} catch { } catch {
alert('Failed to delete reply'); notify({ message: 'Failed to delete reply', severity: 'error' });
} }
}; };
+7 -2
View File
@@ -16,6 +16,7 @@ import api from '../lib/api';
import RatingBadge from '../components/RatingBadge'; import RatingBadge from '../components/RatingBadge';
import StatusBadge from '../components/StatusBadge'; import StatusBadge from '../components/StatusBadge';
import { PageShell, HeroBackdrop, GlassCard, SectionHeader } from '../components/ui'; import { PageShell, HeroBackdrop, GlassCard, SectionHeader } from '../components/ui';
import { useToast } from '../hooks/useToast';
interface Rating { interface Rating {
id: number; id: number;
@@ -55,6 +56,7 @@ const FlavorList: React.FC = () => {
const [flavors, setFlavors] = useState<Flavor[]>([]); const [flavors, setFlavors] = useState<Flavor[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [ratingInput, setRatingInput] = useState<Record<number, RatingInput>>({}); const [ratingInput, setRatingInput] = useState<Record<number, RatingInput>>({});
const { notify } = useToast();
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@@ -82,9 +84,12 @@ const FlavorList: React.FC = () => {
}); });
const res = await api.get('flavors/'); const res = await api.get('flavors/');
setFlavors(res.data); setFlavors(res.data);
alert('Rating submitted!'); notify({ message: 'Rating submitted!', severity: 'success' });
} catch { } catch {
alert("Failed to submit rating. Make sure you are logged in and haven't rated this yet."); notify({
message: "Failed to submit rating. Make sure you are logged in and haven't rated this yet.",
severity: 'error',
});
} }
}; };
+10 -5
View File
@@ -34,6 +34,8 @@ import RichText from '../components/RichText';
import DynamicBanner from '../components/DynamicBanner'; import DynamicBanner from '../components/DynamicBanner';
import { formatDate } from '../utils/date'; import { formatDate } from '../utils/date';
import { PageShell, GlassCard, GlassSurface, FormCard, EmptyState } from '../components/ui'; import { PageShell, GlassCard, GlassSurface, FormCard, EmptyState } from '../components/ui';
import { useToast } from '../hooks/useToast';
import { useConfirm } from '../hooks/useConfirm';
interface Rating { interface Rating {
id: number; id: number;
@@ -94,6 +96,8 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
} | null>(null); } | null>(null);
const [palette, setPalette] = useState<string[]>([]); const [palette, setPalette] = useState<string[]>([]);
const [newComment, setNewComment] = useState(''); const [newComment, setNewComment] = useState('');
const { notify } = useToast();
const { confirm } = useConfirm();
const fetchProfile = useCallback(async () => { const fetchProfile = useCallback(async () => {
try { try {
@@ -158,7 +162,7 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
setData({ ...data, is_following: true, followers_count: data.followers_count + 1 }); setData({ ...data, is_following: true, followers_count: data.followers_count + 1 });
} }
} catch { } catch {
alert('Failed to update follow status. Please login.'); notify({ message: 'Failed to update follow status. Please login.', severity: 'error' });
} }
}; };
@@ -168,7 +172,7 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
else await api.post(`users/${user.id}/follow/`); else await api.post(`users/${user.id}/follow/`);
fetchProfile(); fetchProfile();
} catch { } catch {
alert('Failed'); notify({ message: 'Failed', severity: 'error' });
} }
}; };
@@ -180,17 +184,18 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
setNewComment(''); setNewComment('');
fetchProfile(); fetchProfile();
} catch { } catch {
alert('Failed to add comment'); notify({ message: 'Failed to add comment', severity: 'error' });
} }
}; };
const handleDeleteComment = async (commentId: number) => { const handleDeleteComment = async (commentId: number) => {
if (!data || !confirm('Delete this comment?')) return; if (!data) return;
if (!(await confirm({ message: 'Delete this comment?', danger: true }))) return;
try { try {
await api.delete(`users/${data.id}/delete_comment/${commentId}/`); await api.delete(`users/${data.id}/delete_comment/${commentId}/`);
fetchProfile(); fetchProfile();
} catch { } catch {
alert('Failed to delete comment'); notify({ message: 'Failed to delete comment', severity: 'error' });
} }
}; };
+9 -1
View File
@@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next';
import { useTitle } from '../hooks/useTitle'; import { useTitle } from '../hooks/useTitle';
import type { CatppuccinTheme } from '../theme'; import type { CatppuccinTheme } from '../theme';
import { PageShell, GlassCard, FormCard } from '../components/ui'; import { PageShell, GlassCard, FormCard } from '../components/ui';
import { useConfirm } from '../hooks/useConfirm';
interface SettingsProps { interface SettingsProps {
themeName: CatppuccinTheme; themeName: CatppuccinTheme;
@@ -39,6 +40,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
useTitle(t('nav.settings')); useTitle(t('nav.settings'));
const { confirm } = useConfirm();
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [currentEmail, setCurrentEmail] = useState(''); const [currentEmail, setCurrentEmail] = useState('');
@@ -177,7 +179,13 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
const handleConfirmDeletion = async (e: React.FormEvent) => { const handleConfirmDeletion = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!window.confirm('Are you absolutely sure? This cannot be undone.')) return; const ok = await confirm({
title: 'Delete account',
message: 'Are you absolutely sure? This cannot be undone.',
confirmLabel: 'Delete forever',
danger: true,
});
if (!ok) return;
try { try {
await api.post('users/confirm_account_deletion/', { code: deletionCode }); await api.post('users/confirm_account_deletion/', { code: deletionCode });
localStorage.removeItem('access'); localStorage.removeItem('access');
+9 -5
View File
@@ -25,6 +25,8 @@ import api from '../lib/api';
import { useTitle } from '../hooks/useTitle'; import { useTitle } from '../hooks/useTitle';
import { formatDate } from '../utils/date'; import { formatDate } from '../utils/date';
import { PageShell, SectionHeader, GlassCard, FormCard, EmptyState } from '../components/ui'; import { PageShell, SectionHeader, GlassCard, FormCard, EmptyState } from '../components/ui';
import { useToast } from '../hooks/useToast';
import { useConfirm } from '../hooks/useConfirm';
interface Message { interface Message {
id: number; id: number;
@@ -78,6 +80,8 @@ const Support: React.FC = () => {
const [replyText, setReplyText] = useState<Record<number, string>>({}); const [replyText, setReplyText] = useState<Record<number, string>>({});
const [expandedId, setExpandedId] = useState<number | null>(null); const [expandedId, setExpandedId] = useState<number | null>(null);
const { notify } = useToast();
const { confirm } = useConfirm();
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null); const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
const [unreadTicketIds, setUnreadTicketIds] = useState<Set<number>>(new Set()); const [unreadTicketIds, setUnreadTicketIds] = useState<Set<number>>(new Set());
@@ -131,7 +135,7 @@ const Support: React.FC = () => {
setShowCreate(false); setShowCreate(false);
fetchTickets(); fetchTickets();
} catch { } catch {
alert('Failed to create ticket'); notify({ message: 'Failed to create ticket', severity: 'error' });
} }
}; };
@@ -143,7 +147,7 @@ const Support: React.FC = () => {
setReplyText({ ...replyText, [ticketId]: '' }); setReplyText({ ...replyText, [ticketId]: '' });
fetchTickets(); fetchTickets();
} catch { } catch {
alert('Failed to send message'); notify({ message: 'Failed to send message', severity: 'error' });
} }
}; };
@@ -152,17 +156,17 @@ const Support: React.FC = () => {
await api.post(`tickets/${ticketId}/update_status/`, { status }); await api.post(`tickets/${ticketId}/update_status/`, { status });
fetchTickets(); fetchTickets();
} catch { } catch {
alert('Failed to update status'); notify({ message: 'Failed to update status', severity: 'error' });
} }
}; };
const handleDeleteTicket = async (ticketId: number) => { const handleDeleteTicket = async (ticketId: number) => {
if (!confirm('Delete this ticket?')) return; if (!(await confirm({ message: 'Delete this ticket?', danger: true }))) return;
try { try {
await api.delete(`tickets/${ticketId}/`); await api.delete(`tickets/${ticketId}/`);
fetchTickets(); fetchTickets();
} catch { } catch {
alert('Failed to delete ticket'); notify({ message: 'Failed to delete ticket', severity: 'error' });
} }
}; };
+15 -12
View File
@@ -41,6 +41,8 @@ import {
SectionHeader, SectionHeader,
EmptyState, EmptyState,
} from '../../components/ui'; } from '../../components/ui';
import { useToast } from '../../hooks/useToast';
import { useConfirm } from '../../hooks/useConfirm';
interface Reply { interface Reply {
id: number; id: number;
@@ -83,6 +85,8 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
const theme = useTheme(); const theme = useTheme();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { notify } = useToast();
const { confirm } = useConfirm();
const [flavor, setFlavor] = useState<Flavor | null>(null); const [flavor, setFlavor] = useState<Flavor | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [replyInputs, setReplyInputs] = useState<{ [key: number]: string }>({}); const [replyInputs, setReplyInputs] = useState<{ [key: number]: string }>({});
@@ -152,7 +156,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
setIsAdminEditing(false); setIsAdminEditing(false);
fetchFlavor(); fetchFlavor();
} catch { } catch {
alert('Admin update failed'); notify({ message: 'Admin update failed', severity: 'error' });
} }
}; };
@@ -170,7 +174,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
}); });
fetchFlavor(); fetchFlavor();
} catch { } catch {
alert('Image upload failed'); notify({ message: 'Image upload failed', severity: 'error' });
} finally { } finally {
setUploadingImage(false); setUploadingImage(false);
} }
@@ -179,7 +183,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
const handleRatingSubmit = async (e: React.FormEvent) => { const handleRatingSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!newScore) { if (!newScore) {
alert('Please select a score'); notify({ message: 'Please select a score', severity: 'warning' });
return; return;
} }
try { try {
@@ -189,7 +193,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
fetchFlavor(); fetchFlavor();
} catch (err) { } catch (err) {
const msg = (err as { response?: { data?: { error?: string } } }).response?.data?.error; const msg = (err as { response?: { data?: { error?: string } } }).response?.data?.error;
alert(msg || 'Failed to submit rating'); notify({ message: msg || 'Failed to submit rating', severity: 'error' });
} }
}; };
@@ -199,11 +203,10 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
try { try {
await api.post(`ratings/${ratingId}/reply/`, { text }); await api.post(`ratings/${ratingId}/reply/`, { text });
setReplyInputs({ ...replyInputs, [ratingId]: '' }); setReplyInputs({ ...replyInputs, [ratingId]: '' });
// Auto-expand replies when submitting a new one
setExpandedReplies((prev) => ({ ...prev, [ratingId]: true })); setExpandedReplies((prev) => ({ ...prev, [ratingId]: true }));
fetchFlavor(); fetchFlavor();
} catch { } catch {
alert('Failed to submit reply'); notify({ message: 'Failed to submit reply', severity: 'error' });
} }
}; };
@@ -214,27 +217,27 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
setEditingReplyId(null); setEditingReplyId(null);
fetchFlavor(); fetchFlavor();
} catch { } catch {
alert('Failed to update reply'); notify({ message: 'Failed to update reply', severity: 'error' });
} }
}; };
const handleDeleteReply = async (replyId: number) => { const handleDeleteReply = async (replyId: number) => {
if (!confirm('Delete this reply?')) return; if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return;
try { try {
await api.delete(`replies/${replyId}/`); await api.delete(`replies/${replyId}/`);
fetchFlavor(); fetchFlavor();
} catch { } catch {
alert('Failed to delete reply'); notify({ message: 'Failed to delete reply', severity: 'error' });
} }
}; };
const handleDeleteRating = async (ratingId: number) => { const handleDeleteRating = async (ratingId: number) => {
if (!confirm('Are you sure?')) return; if (!(await confirm({ message: 'Delete this review?', danger: true }))) return;
try { try {
await api.delete(`ratings/${ratingId}/`); await api.delete(`ratings/${ratingId}/`);
fetchFlavor(); fetchFlavor();
} catch { } catch {
alert('Failed to delete review'); notify({ message: 'Failed to delete review', severity: 'error' });
} }
}; };
@@ -250,7 +253,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
setEditMode(null); setEditMode(null);
fetchFlavor(); fetchFlavor();
} catch { } catch {
alert('Failed to update review'); notify({ message: 'Failed to update review', severity: 'error' });
} }
}; };