feat(web): add settings sub-pages and connect to backend APIs
- Add GeneralSettingsPage for language, privacy, and notification settings - Add FeedbackPage for user feedback submission with image upload - Connect settings to backend PATCH /users/me/settings API - Implement language preference sync between frontend and backend - Update login flow to pass language preference and redirect based on user settings - Add Astro entry pages for /settings/general and /settings/feedback routes - Update sidebar navigation: language button links to general settings - Fix account data card to link to profile page - Remove "deletion" text from account data description
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { authFetch } from '../lib/auth';
|
||||
import { API_ROUTES } from '../lib/api-routes';
|
||||
import { apiUrl, jsonHeaders } from '../lib/api-client';
|
||||
|
||||
interface Props {
|
||||
locale: string;
|
||||
feedback: { title: string; typeLabel: string; typeBug: string; typeSuggestion: string; typeOther: string; contentLabel: string; contentPlaceholder: string; imagesLabel: string; anonymousLabel: string; anonymousHint: string; submitBtn: string; submitting: string; success: string; error: string; contentRequired: string; contentTooLong: string; tooManyImages: string; imageTooLarge: string };
|
||||
}
|
||||
|
||||
type FeedbackType = 'bug' | 'suggestion' | 'other';
|
||||
|
||||
const MAX_IMAGES = 3;
|
||||
const MAX_CONTENT_SIZE = 500;
|
||||
const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
export default function FeedbackPage({ locale, feedback: f }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const [selectedType, setSelectedType] = useState<FeedbackType>('bug');
|
||||
const [content, setContent] = useState('');
|
||||
const [images, setImages] = useState<File[]>([]);
|
||||
const [isAnonymous, setIsAnonymous] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const typeOptions: { value: FeedbackType; label: string }[] = [
|
||||
{ value: 'bug', label: f.typeBug },
|
||||
{ value: 'suggestion', label: f.typeSuggestion },
|
||||
{ value: 'other', label: f.typeOther },
|
||||
];
|
||||
|
||||
const handleImagePick = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (images.length >= MAX_IMAGES) {
|
||||
setToast({ type: 'error', message: f.tooManyImages });
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > MAX_IMAGE_SIZE) {
|
||||
setToast({ type: 'error', message: f.imageTooLarge });
|
||||
return;
|
||||
}
|
||||
|
||||
setImages([...images, file]);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleRemoveImage = (index: number) => {
|
||||
setImages(images.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmedContent = content.trim();
|
||||
if (!trimmedContent) {
|
||||
setToast({ type: 'error', message: f.contentRequired });
|
||||
return;
|
||||
}
|
||||
if (trimmedContent.length > MAX_CONTENT_SIZE) {
|
||||
setToast({ type: 'error', message: f.contentTooLong });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setToast(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('feedback_type', selectedType);
|
||||
formData.append('content', trimmedContent);
|
||||
formData.append('device_info', JSON.stringify({ platform: 'web', model: navigator.userAgent }));
|
||||
formData.append('app_version', '1.0.0');
|
||||
formData.append('os_version', navigator.platform);
|
||||
|
||||
for (const image of images) {
|
||||
formData.append('images', image);
|
||||
}
|
||||
|
||||
if (isAnonymous) {
|
||||
// Anonymous submission - no auth header
|
||||
const res = await fetch(apiUrl(API_ROUTES.feedback.submit), {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error('Submit failed');
|
||||
}
|
||||
} else {
|
||||
// Authenticated submission
|
||||
await authFetch(API_ROUTES.feedback.submit, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
setToast({ type: 'success', message: f.success });
|
||||
setTimeout(() => navigate(-1), 1500);
|
||||
} catch {
|
||||
setToast({ type: 'error', message: f.error });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 min-h-full">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="w-8 h-8 rounded-lg bg-white border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-rounded text-slate-500 text-lg">arrow_back</span>
|
||||
</button>
|
||||
<h1 className="text-slate-900 text-xl font-bold">{f.title}</h1>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`px-4 py-3 rounded-lg text-sm ${toast.type === 'success' ? 'bg-green-50 text-green-600' : 'bg-red-50 text-red-500'}`}>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback Type */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4">
|
||||
<h3 className="text-slate-900 text-lg font-bold">{f.typeLabel}</h3>
|
||||
<div className="flex gap-2">
|
||||
{typeOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setSelectedType(opt.value)}
|
||||
className={`flex-1 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${selectedType === opt.value ? 'bg-violet-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4">
|
||||
<h3 className="text-slate-900 text-lg font-bold">{f.contentLabel}</h3>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={f.contentPlaceholder}
|
||||
rows={6}
|
||||
maxLength={MAX_CONTENT_SIZE}
|
||||
className="w-full px-4 py-3 rounded-xl bg-slate-50 border border-slate-200 text-sm focus:outline-none focus:border-violet-400 focus:ring-1 focus:ring-violet-400 resize-none"
|
||||
/>
|
||||
<p className="text-slate-400 text-xs text-right">{content.length}/{MAX_CONTENT_SIZE}</p>
|
||||
</div>
|
||||
|
||||
{/* Images */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4">
|
||||
<h3 className="text-slate-900 text-lg font-bold">{f.imagesLabel}</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{images.map((img, idx) => (
|
||||
<div key={idx} className="relative w-20 h-20 rounded-xl border border-slate-200 overflow-hidden bg-slate-50">
|
||||
<img src={URL.createObjectURL(img)} alt="Preview" className="w-full h-full object-cover" />
|
||||
<button
|
||||
onClick={() => handleRemoveImage(idx)}
|
||||
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center text-xs hover:bg-red-600"
|
||||
>
|
||||
<span className="material-symbols-rounded text-sm">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{images.length < MAX_IMAGES && (
|
||||
<label className="w-20 h-20 rounded-xl border border-dashed border-slate-300 bg-slate-50 flex items-center justify-center cursor-pointer hover:bg-slate-100 transition-colors">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={handleImagePick}
|
||||
className="hidden"
|
||||
/>
|
||||
<span className="material-symbols-rounded text-slate-400 text-2xl">add_photo_alternate</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anonymous Option */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAnonymous}
|
||||
onChange={(e) => setIsAnonymous(e.target.checked)}
|
||||
className="mt-1 w-4 h-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-slate-900 text-sm font-medium">{f.anonymousLabel}</p>
|
||||
<p className="text-slate-400 text-xs mt-0.5">{f.anonymousHint}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
className="w-full py-3.5 rounded-xl bg-violet-600 text-white text-sm font-semibold hover:bg-violet-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{submitting ? f.submitting : f.submitBtn}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user