@@ -7,41 +7,35 @@ import {
77 safeAvatarBg ,
88 emojiFallbackFor ,
99 isImageFileName ,
10- localMascotUrl ,
1110 resolveAvatarValue ,
1211 fetchCodexPets ,
13- fetchLocalMascots ,
14- MASCOTS_DIR_DEFAULT ,
1512} from '../lib/avatars'
1613
1714// WHY: Popup đổi avatar — tham khảo AvatarPickerDialog của web english-topics:
18- // 3 tab (Codex Pets / Emojis / URL tự nhập ) + preview lớn + chọn màu nền đại diện.
19- // Lưu 2 thứ về Supabase: user_profiles.avatar_emoji (giá trị avatar — emoji, mã
20- // codex:<slug> hoặc URL ) và user_settings.settings_json.avatar_bg (class màu nền,
21- // đúng cấu trúc bên web để 2 app đọc/ghi tương thích lẫn nhau).
15+ // 2 tab (Codex Pets / Emojis) + preview lớn + chọn màu nền đại diện.
16+ // Lưu 2 thứ về Supabase: user_profiles.avatar_emoji (giá trị avatar — emoji hoặc mã
17+ // codex:<slug>) và user_settings.settings_json.avatar_bg (class màu nền, đúng cấu
18+ // trúc bên web để 2 app đọc/ghi tương thích lẫn nhau).
2219interface AvatarPickerDialogProps {
2320 open : boolean
2421 userId : string
2522 // WHY: Giá trị avatar hiện tại (avatar_emoji) để khởi tạo lựa chọn — có thể là
26- // emoji, `codex:<slug>` hoặc URL ảnh .
23+ // emoji hoặc `codex:<slug>`.
2724 currentValue : string | null
2825 // WHY: Class màu nền hiện tại (settings_json.avatar_bg) — null nếu chưa từng lưu.
2926 currentBg : string | null
30- // WHY: Thư mục linh vật CỤC BỘ hiện tại (settings_json.mascots_dir) — dùng để khởi
31- // tạo ô nhập thư mục trong tab "Cục bộ" và preview tên file ảnh.
32- currentMascotsDir : string | null
3327 onClose : ( ) => void
3428 // WHY: Báo UserMenu biết đã lưu xong (kèm giá trị mới) để refresh avatar sidebar.
3529 onSaved : ( value : string , bg : string ) => void
3630}
3731
38- type PickerTab = 'codex' | 'emoji' | 'local' | 'url'
32+ type PickerTab = 'codex' | 'emoji'
3933
4034// WHY: Giá trị mặc định khi chưa có avatar — biểu tượng 👤 như bên web.
4135const DEFAULT_AVATAR = '👤'
4236
4337export default function AvatarPickerDialog ( {
44- open, userId, currentValue, currentBg, currentMascotsDir , onClose, onSaved,
38+ open, userId, currentValue, currentBg, onClose, onSaved,
4539} : AvatarPickerDialogProps ) {
4640 const [ tab , setTab ] = useState < PickerTab > ( 'codex' )
4741 // WHY: Giá trị avatar đang chọn (chưa lưu) — khởi tạo từ avatar hiện tại.
@@ -54,41 +48,21 @@ export default function AvatarPickerDialog({
5448 // WHY: Các slug linh vật bị 404 khi tải ảnh (bị gỡ khỏi upstream) → fallback emoji
5549 // thay vì hiện khung vỡ (giống logic getEmojiFallback bên web).
5650 const [ brokenPets , setBrokenPets ] = useState < Set < string > > ( new Set ( ) )
57- // WHY: Thư mục linh vật CỤC BỘ — mặc định public/mascots của web english-topics; user
58- // có thể sửa đường dẫn máy thật. Lưu vào settings_json.mascots_dir khi save.
59- const [ mascotsDir , setMascotsDir ] = useState < string > ( ( ) => currentMascotsDir || MASCOTS_DIR_DEFAULT )
60- const [ localMascots , setLocalMascots ] = useState < string [ ] > ( [ ] )
61- const [ localLoading , setLocalLoading ] = useState ( false )
62- // WHY: File ảnh cục bộ bị lỗi khi tải (file hỏng/thiếu) → fallback emoji theo tên file.
63- const [ localBroken , setLocalBroken ] = useState < Set < string > > ( new Set ( ) )
6451 const [ previewError , setPreviewError ] = useState ( false )
6552 const [ saving , setSaving ] = useState ( false )
6653 const [ error , setError ] = useState < string | null > ( null )
6754 const [ saved , setSaved ] = useState ( false )
6855
69- // WHY: (Re)load danh sách linh vật cục bộ từ thư mục (qua backend) — dùng cho cả lúc
70- // mở popup lẫn nút "Làm mới" khi user sửa đường dẫn thư mục.
71- const reloadLocal = async ( dir : string ) => {
72- setLocalLoading ( true )
73- setLocalBroken ( new Set ( ) )
74- const files = await fetchLocalMascots ( dir )
75- setLocalMascots ( files )
76- setLocalLoading ( false )
77- }
78-
7956 // WHY: Mỗi lần mở popup — reset trạng thái + nạp lại danh sách Codex Pets (fresh),
8057 // đồng thời đồng bộ lựa chọn với giá trị hiện tại (user có thể đã đổi từ nơi khác).
8158 useEffect ( ( ) => {
8259 if ( ! open ) return
8360 setValue ( currentValue || DEFAULT_AVATAR )
8461 setBg ( safeAvatarBg ( currentBg ) )
85- const dir = currentMascotsDir || MASCOTS_DIR_DEFAULT
86- setMascotsDir ( dir )
8762 setPreviewError ( false )
8863 setError ( null )
8964 setSaved ( false )
9065 setBrokenPets ( new Set ( ) )
91- setLocalBroken ( new Set ( ) )
9266 let disposed = false
9367 ; ( async ( ) => {
9468 setLoadingPets ( true )
@@ -99,9 +73,6 @@ export default function AvatarPickerDialog({
9973 setPetsError ( list . length === 0 )
10074 setLoadingPets ( false )
10175 } ) ( )
102- // WHY: Load luôn danh sách linh vật cục bộ (thư mục mặc định/đã lưu) — tránh
103- // chờ user bấm "Làm mới" mới thấy có gì trong tab.
104- reloadLocal ( dir )
10576 return ( ) => { disposed = true }
10677 // eslint-disable-next-line react-hooks/exhaustive-deps
10778 } , [ open ] )
@@ -117,9 +88,9 @@ export default function AvatarPickerDialog({
11788 const trimmedValue = value . trim ( )
11889
11990 // WHY: Resolve giá trị avatar đang chọn sang cách hiển thị (ảnh/emoji) — xử lý cả
120- // codex:<slug> → R2, URL/path tự nhập, tên file ảnh (dog.png) → linh vật CỤC BỘ qua
121- // backend, còn lại là emoji (dùng chung resolveAvatarValue với sidebar ).
122- const resolvedPreview = resolveAvatarValue ( trimmedValue , mascotsDir )
91+ // codex:<slug> → R2 và emoji (dùng chung resolveAvatarValue với sidebar, vẫn hỗ trợ
92+ // URL/tên file nếu avatar cũ đang lưu dạng đó ).
93+ const resolvedPreview = resolveAvatarValue ( trimmedValue )
12394 const isImage = resolvedPreview . isImage && ! ! resolvedPreview . src
12495 const previewSrc = resolvedPreview . src
12596
@@ -172,9 +143,6 @@ export default function AvatarPickerDialog({
172143 const nextJson = {
173144 ...( ( settings ?. settings_json as Record < string , unknown > ) || { } ) ,
174145 avatar_bg : bg ,
175- // WHY: Lưu thư mục linh vật cục bộ cùng avatar — sidebar cần biết thư mục
176- // để render avatar dạng tên file (dog.png) khi mở lại app.
177- mascots_dir : mascotsDir ,
178146 }
179147 if ( settings ) {
180148 await supabase
@@ -202,8 +170,8 @@ export default function AvatarPickerDialog({
202170
203171 if ( ! open ) return null
204172
205- // WHY: Nút chuyển tab (Codex Pets / Emojis / Tự nhập ) — tab đang chọn nổi bật màu
206- // emerald, tab khác mờ; dùng chung cho cả 3 tab để tránh lặp JSX.
173+ // WHY: Nút chuyển tab (Codex Pets / Emojis) — tab đang chọn nổi bật màu emerald,
174+ // tab khác mờ; dùng chung cho cả 2 tab để tránh lặp JSX.
207175 const tabBtn = ( t : PickerTab , label : string , icon : string ) => (
208176 < button
209177 type = "button"
@@ -234,7 +202,7 @@ export default function AvatarPickerDialog({
234202 < div className = "w-7 h-7 rounded-lg bg-emerald-500/10 border border-emerald-500/30 flex items-center justify-center text-sm" > 🖼️</ div >
235203 < div >
236204 < h3 className = "text-sm font-bold" style = { { color : 'var(--fg)' } } > Đổi avatar</ h3 >
237- < p className = "text-[10px]" style = { { color : 'var(--fg-dim)' } } > Chọn linh vật Codex Pets, emoji hoặc dán link ảnh </ p >
205+ < p className = "text-[10px]" style = { { color : 'var(--fg-dim)' } } > Chọn linh vật Codex Pets hoặc emoji </ p >
238206 </ div >
239207 </ div >
240208 < button
@@ -303,8 +271,6 @@ export default function AvatarPickerDialog({
303271 < div className = "flex gap-1.5 p-1 rounded-xl shrink-0" style = { { background : 'rgba(255,255,255,0.04)' } } >
304272 { tabBtn ( 'codex' , 'Codex Pets' , '🐾' ) }
305273 { tabBtn ( 'emoji' , 'Emojis' , '😀' ) }
306- { tabBtn ( 'local' , 'Cục bộ' , '🗂️' ) }
307- { tabBtn ( 'url' , 'Tự nhập' , '🔗' ) }
308274 </ div >
309275
310276 < div className = "flex-1 min-h-0 mt-3 overflow-y-auto rounded-xl border p-2" style = { { borderColor : 'var(--border)' } } >
@@ -380,99 +346,6 @@ export default function AvatarPickerDialog({
380346 </ div >
381347 ) }
382348
383- { tab === 'local' && (
384- < div className = "flex flex-col gap-2.5 min-h-40" >
385- { /* WHY: Ô nhập đường dẫn thư mục linh vật CỤC BỘ — mặc định public/
386- mascots của web english-topics trên cùng máy; user sửa đường dẫn
387- máy thật rồi bấm Làm mới (giống web chỉ đọc public/mascots). */ }
388- < div className = "flex gap-2 shrink-0" >
389- < input
390- type = "text"
391- value = { mascotsDir }
392- onChange = { e => setMascotsDir ( e . target . value ) }
393- onKeyDown = { e => { if ( e . key === 'Enter' ) reloadLocal ( mascotsDir ) } }
394- placeholder = "Đường dẫn thư mục linh vật (vd .../english-topics_v2_nextjs/public/mascots)"
395- className = "flex-1 min-w-0 px-3 py-2 text-[11px] rounded-lg border transition-all focus:outline-none focus:ring-2 bg-slate-800/60 border-slate-700 text-slate-100 placeholder-slate-500 focus:border-emerald-500 focus:ring-emerald-500/20"
396- />
397- < button
398- type = "button"
399- onClick = { ( ) => reloadLocal ( mascotsDir ) }
400- className = "px-3.5 py-2 text-[11px] font-semibold rounded-lg border cursor-pointer transition-colors hover:bg-white/5 shrink-0"
401- style = { { color : 'var(--fg-secondary)' , borderColor : 'var(--border)' } }
402- >
403- 🔄 Làm mới
404- </ button >
405- </ div >
406-
407- { localLoading ? (
408- < div className = "flex-1 min-h-32 flex flex-col items-center justify-center gap-2 text-xs" style = { { color : 'var(--fg-dim)' } } >
409- < span className = "w-5 h-5 border-2 border-emerald-400/20 border-t-emerald-400 rounded-full animate-spin" />
410- Đang đọc thư mục...
411- </ div >
412- ) : localMascots . length === 0 ? (
413- < div className = "flex-1 min-h-32 flex flex-col items-center justify-center gap-1.5 text-xs text-center px-4" style = { { color : 'var(--fg-dim)' } } >
414- < span className = "text-xl" > 🗂️</ span >
415- Không tìm thấy linh vật nào trong thư mục.
416- < span className = "text-[10px] max-w-xs" > Đưa file ảnh (png/jpg/gif/webp/svg) vào thư mục rồi bấm Làm mới.</ span >
417- </ div >
418- ) : (
419- < div className = "grid grid-cols-4 sm:grid-cols-5 gap-2" >
420- { localMascots . map ( file => {
421- const selected = trimmedValue === file
422- const broken = localBroken . has ( file )
423- return (
424- < button
425- type = "button"
426- key = { file }
427- onClick = { ( ) => { setValue ( file ) ; setPreviewError ( false ) } }
428- title = { file }
429- className = { `w-full aspect-square rounded-xl border-2 transition-all duration-150 cursor-pointer flex items-center justify-center overflow-hidden p-0.5 ${
430- selected
431- ? 'border-emerald-400 ring-2 ring-emerald-400/30'
432- : 'border-transparent hover:border-white/20 hover:bg-white/5'
433- } `}
434- >
435- { broken ? (
436- < span className = "text-2xl select-none" > { emojiFallbackFor ( file ) } </ span >
437- ) : (
438- < img
439- src = { localMascotUrl ( mascotsDir , file ) }
440- alt = { file }
441- loading = "lazy"
442- className = "w-full h-full object-contain"
443- draggable = { false }
444- onError = { ( ) => setLocalBroken ( prev => new Set ( prev ) . add ( file ) ) }
445- />
446- ) }
447- </ button >
448- )
449- } ) }
450- </ div >
451- ) }
452- </ div >
453- ) }
454-
455- { tab === 'url' && (
456- < div className = "h-full min-h-40 flex flex-col justify-center" >
457- < div className = "max-w-md mx-auto w-full space-y-3 p-4" >
458- < div className = "text-center" >
459- < span className = "text-2xl" > 🔗</ span >
460- < h4 className = "text-sm font-bold mt-1" style = { { color : 'var(--fg)' } } > Dán link ảnh tùy chỉnh</ h4 >
461- < p className = "text-[11px] mt-1" style = { { color : 'var(--fg-dim)' } } > Bất kỳ link ảnh png, jpg, gif, webp nào từ internet</ p >
462- </ div >
463- < input
464- type = "url"
465- value = { value }
466- onChange = { e => { setValue ( e . target . value ) ; setPreviewError ( false ) } }
467- placeholder = "https://.../avatar.png"
468- className = "w-full px-3.5 py-2.5 text-xs rounded-xl border transition-all focus:outline-none focus:ring-2 bg-slate-800/60 border-slate-700 text-slate-100 placeholder-slate-500 focus:border-emerald-500 focus:ring-emerald-500/20"
469- />
470- < p className = "text-[10px] px-2" style = { { color : 'var(--fg-dim)' } } >
471- ⚠️ Đảm bảo link ảnh hoạt động và cho phép hiển thị (CORS enabled). Vd: < span className = "font-mono" > { CODEX_PETS_BASE_URL } /firefly--lingxiaotian/webp/idle.webp</ span >
472- </ p >
473- </ div >
474- </ div >
475- ) }
476349 </ div >
477350 </ div >
478351 </ div >
0 commit comments