diff --git a/app/teknav-profile.jsx b/app/teknav-profile.jsx new file mode 100644 index 0000000..789a0ae --- /dev/null +++ b/app/teknav-profile.jsx @@ -0,0 +1,1015 @@ +// User profile page — /profile/@username +// Terracotta / warm-orange / unique-green palette; no teal. + +import { useState, useEffect, useRef } from 'react'; +import { useNav, useAuth, useToast, EmptyState, pillBtn, inputStyle } from './teknav-ui.jsx'; +import { api } from './src/lib/api.js'; +import { engagementApi, pushApi, notificationPreferencesApi } from './src/lib/engagement-api.js'; +import { TeknavData } from './teknav-data.js'; +import { iconNode } from './src/lib/icon.jsx'; +import eyeIcon from './src/assets/icons/eye.svg'; +import gradCapIcon from './src/assets/icons/graduation-cap.svg'; +import scrollIcon from './src/assets/icons/scroll.svg'; +import botIcon from './src/assets/icons/bot.svg'; +import chartIcon from './src/assets/icons/chart-column.svg'; +import lockIcon from './src/assets/icons/lock.svg'; +import laptopIcon from './src/assets/icons/laptop.svg'; +import flameIcon from './src/assets/icons/flame.svg'; +import zapIcon from './src/assets/icons/zap.svg'; +import libraryIcon from './src/assets/icons/library.svg'; +import sproutIcon from './src/assets/icons/sprout.svg'; +import medalIcon from './src/assets/icons/medal.svg'; + +// ── Theme ──────────────────────────────────────────────────────────────────── +const P = { + terracotta: '#C45C3D', + orange: '#E07A3A', + green: '#3A7D5E', + greenLight: '#E8F4EF', + ink: '#263238', + muted: '#5F6B6D', + cream: '#FAF7F0', + panel: '#F4EFE6', + line: '#E4DDD2', + white: '#fff', +}; + +const ROLE_LABELS = { + admin: { label: 'ادمین', bg: 'rgba(196,92,61,0.10)', color: '#C45C3D', border: 'rgba(196,92,61,0.22)' }, + editor: { label: 'سردبیر', bg: 'rgba(58,125,94,0.10)', color: '#2F8F6B', border: 'rgba(58,125,94,0.22)' }, + writer: { label: 'نویسنده', bg: 'rgba(212,154,42,0.10)', color: '#A87A10', border: 'rgba(212,154,42,0.22)' }, + reviewer: { label: 'بازبین', bg: 'rgba(100,120,200,0.10)', color: '#5566AA', border: 'rgba(100,120,200,0.22)' }, + reader: { label: 'خواننده', bg: 'rgba(95,107,109,0.08)', color: '#5F6B6D', border: 'rgba(95,107,109,0.18)' }, +}; + +function RolePill({ role }) { + const r = ROLE_LABELS[role] ?? ROLE_LABELS.reader; + return ( + {r.label} + ); +} + +function findStaticAuthor(slug) { + return TeknavData.authors.find(author => author.username === slug || author.slug === slug); +} + +function getPublishedAuthorArticles(author) { + return TeknavData.articles + .filter(article => ( + (article.authorId === author.id || article.authorName === author.name || article.authorUsername === author.username) + && article.status === 'منتشرشده' + )) + .map(article => ({ + id: article.id, + slug: article.slug, + title: article.title, + summary: article.summary, + publishedAt: article.dateEn ? `${article.dateEn}T09:00:00+03:30` : null, + views: article.views ?? 0, + reactions: article.reactions ?? 0, + })); +} + +function buildStaticAuthorProfile(author) { + if (!author) return null; + const authorArticles = getPublishedAuthorArticles(author); + return { + id: author.id, + username: author.username, + name: author.name, + bio: author.bio, + avatarUrl: author.avatarUrl ?? null, + role: 'writer', + createdAt: author.joinedDate ? new Date(author.joinedDate).toISOString() : null, + author: { + articles: authorArticles, + articleCount: authorArticles.length, + verifiedExpert: author.verifiedExpert ?? false, + verificationNote: author.verificationNote ?? author.specialty, + }, + _static: true, + expertise: author.expertise ?? [], + education: author.education ?? null, + alumniOf: author.alumniOf ?? null, + credential: author.credential ?? null, + social: author.social ?? {}, + specialty: author.specialty, + }; +} + +function mergeProfileWithAuthor(profileData, author) { + if (!profileData || !author) return profileData; + return { + ...profileData, + expertise: profileData.expertise || author.expertise || [], + education: profileData.education || author.education || null, + alumniOf: profileData.alumniOf || author.alumniOf || null, + credential: profileData.credential || author.credential || null, + avatarUrl: profileData.avatarUrl || author.avatarUrl || null, + social: { ...author.social, ...profileData.social }, + specialty: profileData.specialty || author.specialty, + author: { + ...profileData.author, + verifiedExpert: profileData.author?.verifiedExpert ?? author.verifiedExpert ?? false, + verificationNote: profileData.author?.verificationNote ?? author.verificationNote ?? author.specialty, + } + }; +} + +// ── Avatar with upload overlay ─────────────────────────────────────────────── +function AvatarUpload({ user, editable, onUploaded }) { + const inputRef = useRef(null); + const [busy, setBusy] = useState(false); + const [webp, setWebp] = useState(true); + const [preview, setPreview] = useState(user.avatarUrl ?? null); + + useEffect(() => { + setPreview(user.avatarUrl ?? null); + }, [user.avatarUrl]); + + const handleFile = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + if (file.size > 5 * 1024 * 1024) { alert('حجم تصویر نباید بیشتر از ۵ مگابایت باشد.'); return; } + setBusy(true); + try { + const dataUrl = await new Promise((res, rej) => { + const fr = new FileReader(); + fr.onload = () => res(fr.result); + fr.onerror = rej; + fr.readAsDataURL(file); + }); + const base64 = dataUrl.split(',')[1]; + const data = await api.post('/api/auth/avatar', { file: base64, mimeType: file.type, webp }); + setPreview(data.url); + onUploaded?.(data.url); + } catch { + alert('آپلود آواتار ناموفق بود. دوباره تلاش کنید.'); + } finally { + setBusy(false); + } + }; + + const initials = user.name ? user.name.slice(0, 2) : '؟'; + const avatarStyle = { + width: 110, height: 110, borderRadius: '50%', + border: `3px solid rgba(196,92,61,0.35)`, + boxShadow: '0 4px 20px rgba(196,92,61,0.15)', + objectFit: 'cover', display: 'block', + }; + + return ( +
+ {preview + ? {user.name} + : ( +
+ {initials} +
+ ) + } + {editable && ( + <> + + + + + )} +
+ ); +} + +// ── Inline edit field ──────────────────────────────────────────────────────── +function EditField({ label, value, onChange, multiline, placeholder, maxLength }) { + const style = { + ...inputStyle, + fontSize: 14, padding: '8px 12px', marginTop: 4, resize: multiline ? 'vertical' : 'none', + minHeight: multiline ? 80 : undefined, + }; + return ( +