diff --git a/src/api/client.js b/src/api/client.js index 0b00a09..13b677f 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -296,109 +296,6 @@ export async function pollNewPosts(params = {}) { * Replace with real API call when island-service is ready */ export async function fetchIslands(params = {}) { - // MOCK DATA - Remove this and uncomment API call when backend is ready - console.log('[fetchIslands] Using MOCK data', params); - - // Simulate network delay - await new Promise(resolve => setTimeout(resolve, 300)); - - const mockIslands = [ - { - id: 'island-1', - user_id: 'user-alice', - username: 'alice', - seed: 12345678, - virtual_x: -73.5, - virtual_y: 45.5, - virtual_z: 0, - terrain_type: 'tropical', - color_palette: 'vibrant', - size: 'medium', - display_name: "Alice's Paradise", - bio: 'Welcome to my tropical island! 🌴', - avatar_url: null, - content_count: 12, - visitors: 234, - created_at: '2025-01-15T10:00:00Z' - }, - { - id: 'island-2', - user_id: 'user-bob', - username: 'bob', - seed: 87654321, - virtual_x: -74.0, - virtual_y: 45.8, - virtual_z: 0, - terrain_type: 'desert', - color_palette: 'sunset', - size: 'large', - display_name: "Bob's Oasis", - bio: 'Desert vibes only ☀️', - avatar_url: null, - content_count: 8, - visitors: 156, - created_at: '2025-01-10T14:30:00Z' - }, - { - id: 'island-3', - user_id: 'user-charlie', - username: 'charlie', - seed: 11223344, - virtual_x: -73.2, - virtual_y: 45.3, - virtual_z: 0, - terrain_type: 'arctic', - color_palette: 'monochrome', - size: 'small', - display_name: "Charlie's Iceberg", - bio: 'Chill zone ❄️', - avatar_url: null, - content_count: 5, - visitors: 89, - created_at: '2025-01-20T09:15:00Z' - }, - { - id: 'island-4', - user_id: 'user-diana', - username: 'diana', - seed: 99887766, - virtual_x: -73.8, - virtual_y: 45.6, - virtual_z: 0, - terrain_type: 'volcanic', - color_palette: 'vibrant', - size: 'medium', - display_name: "Diana's Volcano", - bio: 'Hot takes and lava cakes 🌋', - avatar_url: null, - content_count: 15, - visitors: 312, - created_at: '2025-01-12T16:45:00Z' - }, - { - id: 'island-5', - user_id: 'user-eve', - username: 'eve', - seed: 55443322, - virtual_x: -74.2, - virtual_y: 45.4, - virtual_z: 0, - terrain_type: 'meadow', - color_palette: 'pastel', - size: 'large', - display_name: "Eve's Garden", - bio: 'Peace, love, and flowers 🌸', - avatar_url: null, - content_count: 20, - visitors: 445, - created_at: '2025-01-08T11:20:00Z' - } - ]; - - return mockIslands; - - /* - // REAL API CALL - Uncomment when island-service is ready: const qs = new URLSearchParams(); if (typeof params.lat === "number") qs.set("lat", String(params.lat)); @@ -406,7 +303,7 @@ export async function fetchIslands(params = {}) { if (typeof params.radius_km === "number") qs.set("radius_km", String(params.radius_km)); if (typeof params.limit === "number") qs.set("limit", String(params.limit)); - const url = `http://localhost:8090/api/islands/nearby?${qs.toString()}`; + const url = `${API_BASE}/islands/nearby?${qs.toString()}`; try { const res = await fetch(url, { credentials: "include" }); @@ -420,7 +317,6 @@ export async function fetchIslands(params = {}) { console.warn("fetchIslands error:", err); return []; } - */ } /** @@ -430,108 +326,8 @@ export async function fetchIslands(params = {}) { * NOTE: Currently using MOCK DATA for testing */ export async function fetchIsland(islandId) { - // MOCK DATA - Remove this and uncomment API call when backend is ready - console.log('[fetchIsland] Using MOCK data for island:', islandId); - - // Simulate network delay - await new Promise(resolve => setTimeout(resolve, 200)); - - // Mock island with content - const mockIslandData = { - 'island-1': { - id: 'island-1', - user_id: 'user-alice', - username: 'alice', - seed: 12345678, - virtual_x: -73.5, - virtual_y: 45.5, - virtual_z: 0, - terrain_type: 'tropical', - color_palette: 'vibrant', - size: 'medium', - display_name: "Alice's Paradise", - bio: 'Welcome to my tropical island! 🌴', - avatar_url: null, - content_count: 3, - visitors: 234, - created_at: '2025-01-15T10:00:00Z', - content: [ - { - id: 'content-1', - island_id: 'island-1', - content_type: 'post', - content_id: 'post-123', - position_x: 0.3, - position_y: 0.7, - position_z: 0.5, - scale: 1.0, - rotation: 0, - created_at: '2025-01-16T10:00:00Z' - }, - { - id: 'content-2', - island_id: 'island-1', - content_type: 'app', - content_id: 'app-456', - position_x: 0.6, - position_y: 0.4, - position_z: 0.5, - scale: 1.0, - rotation: 45, - created_at: '2025-01-17T14:30:00Z' - }, - { - id: 'content-3', - island_id: 'island-1', - content_type: 'post', - content_id: 'post-789', - position_x: 0.8, - position_y: 0.8, - position_z: 0.5, - scale: 1.0, - rotation: 90, - created_at: '2025-01-18T09:15:00Z' - } - ] - }, - 'island-2': { - id: 'island-2', - user_id: 'user-bob', - username: 'bob', - seed: 87654321, - virtual_x: -74.0, - virtual_y: 45.8, - terrain_type: 'desert', - color_palette: 'sunset', - size: 'large', - display_name: "Bob's Oasis", - bio: 'Desert vibes only ☀️', - content_count: 2, - visitors: 156, - content: [ - { - id: 'content-4', - content_type: 'post', - position_x: 0.5, - position_y: 0.5, - position_z: 0.5 - }, - { - id: 'content-5', - content_type: 'document', - position_x: 0.2, - position_y: 0.3, - position_z: 0.5 - } - ] - } - }; - - return mockIslandData[islandId] || null; - - /* - // REAL API CALL - Uncomment when island-service is ready: - const url = `http://localhost:8090/api/islands/${islandId}`; + if (!islandId) return null; + const url = `${API_BASE}/islands/${encodeURIComponent(islandId)}`; try { const res = await fetch(url, { credentials: "include" }); @@ -544,7 +340,45 @@ export async function fetchIsland(islandId) { console.warn("fetchIsland error:", err); return null; } - */ +} + +export async function fetchIslandByUsername(username) { + if (!username) return null; + const url = `${API_BASE}/islands/by-username?username=${encodeURIComponent(username)}`; + try { + const res = await fetch(url, { credentials: "include" }); + if (!res.ok) { + console.warn("fetchIslandByUsername failed", res.status); + return null; + } + return await res.json(); + } catch (err) { + console.warn("fetchIslandByUsername error:", err); + return null; + } +} + +export async function uploadProfileAvatar(file) { + if (!file) return { ok: false }; + const url = `${API_BASE}/profile/avatar`; + const form = new FormData(); + form.append("file", file); + try { + const res = await fetch(url, { + method: "POST", + body: form, + credentials: "include", + headers: { ...authHeaders() }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + return { ok: false, status: res.status, text }; + } + return await res.json(); + } catch (err) { + console.warn("uploadProfileAvatar error:", err); + return { ok: false }; + } } export async function recordPostView(postId) { diff --git a/src/auth/AuthContext.jsx b/src/auth/AuthContext.jsx index ede193c..dd4877c 100644 --- a/src/auth/AuthContext.jsx +++ b/src/auth/AuthContext.jsx @@ -132,6 +132,7 @@ async function me(accessToken) { json, text, emailVerified, + avatarUrl: u && typeof u.avatar_url === "string" ? u.avatar_url : "", }; } @@ -182,6 +183,7 @@ export function AuthProvider({ children }) { const [status, setStatus] = useState("loading"); // loading | anon | auth const [user, setUser] = useState(null); // { username } const [tokens, setTokens] = useState(null); // { access_token, refresh_token, ... } + const [avatarUrl, setAvatarUrl] = useState(""); const [error, setError] = useState(""); const [lastInfo, setLastInfo] = useState(""); @@ -194,6 +196,7 @@ export function AuthProvider({ children }) { setStatus("anon"); setUser(null); setTokens(null); + setAvatarUrl(""); setError(msg || ""); setLastInfo(""); setNeedsEmailVerification(false); @@ -218,6 +221,9 @@ export function AuthProvider({ children }) { // /api/me is the truth; token claim is fallback only const mr = await me(at); let verified = mr.emailVerified + if (mr.avatarUrl) { + setAvatarUrl(mr.avatarUrl); + } if (typeof verified !== "boolean") { const tv = tokenEmailVerified(at); @@ -553,6 +559,7 @@ export function AuthProvider({ children }) { user, tokens, error, + avatarUrl, // expected by UI loading, @@ -571,13 +578,14 @@ export function AuthProvider({ children }) { logout, restore, refreshProfile, + setAvatarUrl, setError, setLastInfo, setNeedsEmailVerification, clearError: () => setError(""), clearInfo: () => setLastInfo(""), }; - }, [status, user, tokens, error, lastInfo, needsEmailVerification]); + }, [status, user, tokens, error, lastInfo, needsEmailVerification, avatarUrl]); return {children}; } diff --git a/src/components/Layout/TopBar.jsx b/src/components/Layout/TopBar.jsx index 079ffb4..6eca19d 100644 --- a/src/components/Layout/TopBar.jsx +++ b/src/components/Layout/TopBar.jsx @@ -62,6 +62,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick } logout, loginWithProvider, ssoEnabled, + avatarUrl, needsEmailVerification, lastError, lastInfo, @@ -317,7 +318,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick } {authenticated ? (
- {initialLetter(username)} + {avatarUrl ? {username} : {initialLetter(username)}}
Online
diff --git a/src/components/Profile/UserProfile.jsx b/src/components/Profile/UserProfile.jsx index bbdcd54..68f5b4b 100644 --- a/src/components/Profile/UserProfile.jsx +++ b/src/components/Profile/UserProfile.jsx @@ -1,5 +1,6 @@ -import React, { useEffect, useState } from 'react'; -import { fetchIsland } from '../../api/client'; +import React, { useEffect, useRef, useState } from 'react'; +import { fetchIslandByUsername, uploadProfileAvatar } from '../../api/client'; +import { useAuth } from '../../auth/AuthContext'; import '../../styles/profile.css'; /** @@ -11,37 +12,26 @@ import '../../styles/profile.css'; * - onVisitIsland: function(island) - called when "Visit Island" is clicked */ export default function UserProfile({ username, onClose, onVisitIsland }) { + const { authenticated, username: authedUser, setAvatarUrl } = useAuth(); const [island, setIsland] = useState(null); const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const fileRef = useRef(null); + + const isSelf = authenticated && authedUser && authedUser.toLowerCase() === username.toLowerCase(); useEffect(() => { if (!username) return; - // Find island by username (mock data uses island-{number}) - // In real backend, we'd call /api/islands/by-username/{username} - const mockIslandMap = { - 'alice': 'island-1', - 'bob': 'island-2', - 'charlie': 'island-3', - 'diana': 'island-4', - 'eve': 'island-5', - }; - - const islandId = mockIslandMap[username.toLowerCase()]; - - if (islandId) { - fetchIsland(islandId) - .then(data => { - setIsland(data); - setLoading(false); - }) - .catch(err => { - console.error('[UserProfile] Error fetching island:', err); - setLoading(false); - }); - } else { - setLoading(false); - } + fetchIslandByUsername(username) + .then(data => { + setIsland(data); + setLoading(false); + }) + .catch(err => { + console.error('[UserProfile] Error fetching island:', err); + setLoading(false); + }); }, [username]); const handleVisitIsland = () => { @@ -50,6 +40,22 @@ export default function UserProfile({ username, onClose, onVisitIsland }) { } }; + const handleChooseAvatar = () => { + if (fileRef.current) fileRef.current.click(); + }; + + const handleAvatarChange = async (e) => { + const file = e.target.files && e.target.files[0]; + if (!file) return; + setUploading(true); + const res = await uploadProfileAvatar(file); + if (res && res.ok && res.avatar_url) { + setIsland(prev => (prev ? { ...prev, avatar_url: res.avatar_url } : prev)); + if (setAvatarUrl) setAvatarUrl(res.avatar_url); + } + setUploading(false); + }; + return (
e.stopPropagation()}> @@ -69,6 +75,25 @@ export default function UserProfile({ username, onClose, onVisitIsland }) { {island?.display_name && island.display_name !== `${username}'s Island` && (

{island.display_name}

)} + {isSelf ? ( +
+ + +
+ ) : null}
{loading ? ( diff --git a/src/styles/profile.css b/src/styles/profile.css index cbb60cf..d265904 100644 --- a/src/styles/profile.css +++ b/src/styles/profile.css @@ -93,6 +93,24 @@ font-style: italic; } +.user-profile-avatar-actions{ + margin-top: 0.4rem; +} +.user-profile-btn-ghost{ + background: rgba(15, 23, 42, 0.2); + color: #e2e8f0; + border: 1px solid rgba(148,163,184,0.45); + padding: 0.35rem 0.8rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 800; + cursor: pointer; +} +.user-profile-btn-ghost:disabled{ + opacity: 0.6; + cursor: not-allowed; +} + /* Bio */ .user-profile-bio { @@ -241,6 +259,12 @@ body[data-theme="light"] .user-profile-display-name { color: rgba(30, 58, 138, 0.7); } +body[data-theme="light"] .user-profile-btn-ghost{ + background: rgba(255, 255, 255, 0.85); + color: #1e293b; + border-color: rgba(99, 102, 241, 0.45); +} + body[data-theme="light"] .user-profile-bio { background: rgba(255, 255, 255, 0.5); } diff --git a/src/styles/topbar.css b/src/styles/topbar.css index 8d44c23..00d0790 100644 --- a/src/styles/topbar.css +++ b/src/styles/topbar.css @@ -496,6 +496,12 @@ body[data-theme="light"] .user-avatar{ flex-shrink: 0; } .sw-avatar i{ font-size: 12px; } +.sw-avatar img{ + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} .sw-usertext{ display: flex;