Wire islands API and avatar uploads

This commit is contained in:
Your Name 2025-12-25 21:54:59 -05:00
parent e1e5999617
commit d06819181e
6 changed files with 135 additions and 237 deletions

View File

@ -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) {

View File

@ -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 <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
}

View File

@ -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 ? (
<div className="sw-userchip" title={username}>
<div className="sw-avatar">
<span>{initialLetter(username)}</span>
{avatarUrl ? <img src={avatarUrl} alt={username} /> : <span>{initialLetter(username)}</span>}
</div>
<div className="sw-usertext">
<div className="sw-userline">Online</div>

View File

@ -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,26 +12,18 @@ 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)
fetchIslandByUsername(username)
.then(data => {
setIsland(data);
setLoading(false);
@ -39,9 +32,6 @@ export default function UserProfile({ username, onClose, onVisitIsland }) {
console.error('[UserProfile] Error fetching island:', err);
setLoading(false);
});
} else {
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 (
<div className="user-profile-modal" onClick={onClose}>
<div className="user-profile-container" onClick={(e) => e.stopPropagation()}>
@ -69,6 +75,25 @@ export default function UserProfile({ username, onClose, onVisitIsland }) {
{island?.display_name && island.display_name !== `${username}'s Island` && (
<p className="user-profile-display-name">{island.display_name}</p>
)}
{isSelf ? (
<div className="user-profile-avatar-actions">
<button
className="user-profile-btn user-profile-btn-ghost"
type="button"
disabled={uploading}
onClick={handleChooseAvatar}
>
{uploading ? 'Uploading...' : 'Change photo'}
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleAvatarChange}
/>
</div>
) : null}
</div>
{loading ? (

View File

@ -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);
}

View File

@ -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;