diff --git a/index.html b/index.html index cdabfc0..00687b7 100644 --- a/index.html +++ b/index.html @@ -9,9 +9,9 @@ - - - + + + @@ -19,7 +19,7 @@ - + @@ -29,7 +29,7 @@ - + @@ -54,7 +54,7 @@ "@type": "Organization", "name": "SocioWire", "url": "https://sociowire.com", - "logo": "https://sociowire.com/logo2026.png", + "logo": "https://sociowire.com/logo.png", "sameAs": [] } diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..5af3427 Binary files /dev/null and b/public/logo.png differ diff --git a/public/logo2026.png b/public/logo2026.png deleted file mode 100644 index 0aff0ad..0000000 Binary files a/public/logo2026.png and /dev/null differ diff --git a/src/api/client.js b/src/api/client.js index ecdcf24..b74784b 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -4,6 +4,21 @@ import { loadAuth } from "../auth/authStorage"; +/** + * Convert image URL to small variant (160x160) + * Asset URLs: ..._l.jpg -> ..._s.webp, ..._m.webp -> ..._s.webp + */ +export function toSmallImageUrl(url) { + if (!url || typeof url !== "string") return url; + if (url.includes("_l.jpg") || url.includes("_l.jpeg")) { + return url.replace(/_l\.jpe?g/i, "_s.webp"); + } + if (url.includes("_m.webp")) { + return url.replace("_m.webp", "_s.webp"); + } + return url; +} + function apiBase() { const raw = (import.meta?.env?.VITE_API_BASE_URL || "") @@ -1043,3 +1058,40 @@ export async function fetchWeatherPosts() { if (!res.ok) return []; return res.json(); } + +/** + * Change password for the authenticated user + * @param {string} oldPassword - Current password (optional but recommended) + * @param {string} newPassword - New password (min 8 chars) + */ +export async function changePassword(oldPassword, newPassword) { + const url = `${apiBase()}/change-password`; + try { + const res = await fetch(url, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json", ...authHeaders() }, + body: JSON.stringify({ + old_password: oldPassword || "", + new_password: newPassword, + }), + }); + const text = await res.text().catch(() => ""); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + return { + ok: res.ok, + status: res.status, + json, + error: json?.error || (res.ok ? null : text || "Password change failed"), + message: json?.message || "", + }; + } catch (err) { + console.warn("changePassword error:", err); + return { ok: false, status: 0, json: null, error: err?.message || "error" }; + } +} diff --git a/src/components/Friends/FriendsList.jsx b/src/components/Friends/FriendsList.jsx index 26fa8e2..3396d65 100644 --- a/src/components/Friends/FriendsList.jsx +++ b/src/components/Friends/FriendsList.jsx @@ -9,6 +9,7 @@ import { removeFriend, sendFriendRequest, searchUsers, + toSmallImageUrl, } from '../../api/client'; import { useAuth } from '../../auth/AuthContext'; import './Friends.css'; @@ -228,7 +229,7 @@ export default function FriendsList({ onSelectUser, onClose }) { >
{s.avatar_url ? ( - {s.username} + {s.username} ) : ( s.username[0]?.toUpperCase() || '?' )} @@ -287,7 +288,7 @@ export default function FriendsList({ onSelectUser, onClose }) { >
{request.avatar_url ? ( - {request.username} + {request.username} ) : ( request.username[0]?.toUpperCase() || '?' )} diff --git a/src/components/Island/IslandViewer.jsx b/src/components/Island/IslandViewer.jsx index e32dd1a..a0661a8 100644 --- a/src/components/Island/IslandViewer.jsx +++ b/src/components/Island/IslandViewer.jsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import '../../styles/island.css'; -import { fetchPosts, updateProfileUsername, uploadProfileAvatar, updateTimezone, getBrowserTimezone } from '../../api/client'; +import { fetchPosts, updateProfileUsername, uploadProfileAvatar, updateTimezone, getBrowserTimezone, changePassword, toSmallImageUrl } from '../../api/client'; import { setUserTimezone as saveTimezoneLocal, getUserTimezone as getStoredTimezone } from '../../utils/timezone'; import { useAuth } from '../../auth/AuthContext'; import { trackEvent } from "../../utils/analytics"; @@ -35,6 +35,14 @@ export default function IslandViewer({ island, onClose }) { const [timezone, setTimezone] = useState(getStoredTimezone()); const [timezoneSaving, setTimezoneSaving] = useState(false); const [timezoneInfo, setTimezoneInfo] = useState(""); + // Password change + const [showPasswordForm, setShowPasswordForm] = useState(false); + const [oldPassword, setOldPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [passwordError, setPasswordError] = useState(''); + const [passwordSuccess, setPasswordSuccess] = useState(''); + const [passwordLoading, setPasswordLoading] = useState(false); const islandUsername = localUsername || island?.username || ""; useEffect(() => { @@ -206,7 +214,7 @@ export default function IslandViewer({ island, onClose }) {
{avatarSrc ? ( - {islandUsername} + {islandUsername} ) : ( {(islandUsername || "U")[0].toUpperCase()} )} @@ -222,7 +230,7 @@ export default function IslandViewer({ island, onClose }) {
{avatarSrc ? ( - {islandUsername} + {islandUsername} ) : (
{(islandUsername || "U")[0].toUpperCase()} @@ -399,6 +407,23 @@ export default function IslandViewer({ island, onClose }) {
{timezoneInfo ?
{timezoneInfo}
: null} + +
+ +
) : null}
@@ -472,6 +497,85 @@ export default function IslandViewer({ island, onClose }) {

+ + {/* Password Change Modal */} + {showPasswordForm && ( +
setShowPasswordForm(false)}> +
e.stopPropagation()}> + +

Change Password

+
+
+ + setOldPassword(e.target.value)} + placeholder="Enter current password" + /> +
+
+ + setNewPassword(e.target.value)} + placeholder="Min 8 characters" + /> +
+
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm new password" + /> +
+ {passwordError &&
{passwordError}
} + {passwordSuccess &&
{passwordSuccess}
} + +
+
+
+ )}
); } diff --git a/src/components/Layout/TopBar.jsx b/src/components/Layout/TopBar.jsx index b773e7f..94c4670 100644 --- a/src/components/Layout/TopBar.jsx +++ b/src/components/Layout/TopBar.jsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useAuth } from "../../auth/AuthContext"; -import { registerUser, updateProfileUsername, fetchFriendRequests } from "../../api/client"; +import { registerUser, updateProfileUsername, fetchFriendRequests, toSmallImageUrl } from "../../api/client"; import AuthToast from "../Auth/AuthToast"; import { promptInstall, isRunningAsPWA, canInstall } from '../../pwaInstall'; import { trackEvent } from "../../utils/analytics"; @@ -488,7 +488,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
-
SocioWire
+
SocioWire
SOCIOWIRE.com
{t('topbar.wiredToLife')}
@@ -524,7 +524,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, onClick={() => onUserIsland && onUserIsland(username)} >
- {avatarUrl ? {username} : {initialLetter(username)}} + {avatarUrl ? {username} : {initialLetter(username)}}
{t('common.online')}
diff --git a/src/components/Map/markerManager.js b/src/components/Map/markerManager.js index 5b00728..0333420 100644 --- a/src/components/Map/markerManager.js +++ b/src/components/Map/markerManager.js @@ -31,13 +31,30 @@ const avatarCache = new Map(); const avatarInflight = new Map(); const HEATMAP_ENABLED = false; +/** + * Convert avatar URL to small variant (160x160) + * Asset URLs: ..._l.jpg -> ..._s.webp, ..._m.webp -> ..._s.webp + */ +function toSmallAvatarUrl(url) { + if (!url || typeof url !== "string") return url; + // Convert _l.jpg or _l.jpeg to _s.webp + if (url.includes("_l.jpg") || url.includes("_l.jpeg")) { + return url.replace(/_l\.jpe?g/i, "_s.webp"); + } + // Convert _m.webp to _s.webp + if (url.includes("_m.webp")) { + return url.replace("_m.webp", "_s.webp"); + } + return url; +} + function applyAvatar(el, username, url) { if (!el) return; const initial = (username || "U")[0]?.toUpperCase() || "U"; el.innerHTML = ""; if (url) { const img = document.createElement("img"); - img.src = url; + img.src = toSmallAvatarUrl(url); img.alt = username || "user"; el.appendChild(img); } else { @@ -2589,7 +2606,7 @@ export function createIslandMarker(island, mapRef, markersRef, onIslandClick, th transition: transform 0.2s ease, box-shadow 0.2s ease; "> ${island.avatar_url - ? `${island.username}` + ? `${island.username}` : `
${island.username[0].toUpperCase()}
` }
diff --git a/src/components/Profile/UserProfile.jsx b/src/components/Profile/UserProfile.jsx index c4d6039..2af3db4 100644 --- a/src/components/Profile/UserProfile.jsx +++ b/src/components/Profile/UserProfile.jsx @@ -6,6 +6,7 @@ import { sendFriendRequest, removeFriend, fetchFriends, + changePassword, } from '../../api/client'; import { useAuth } from '../../auth/AuthContext'; import '../../styles/profile.css'; @@ -38,6 +39,15 @@ export default function UserProfile({ username, onClose, onVisitIsland }) { const [showFriends, setShowFriends] = useState(false); const [friends, setFriends] = useState([]); + // Password change state + const [showPasswordForm, setShowPasswordForm] = useState(false); + const [oldPassword, setOldPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [passwordError, setPasswordError] = useState(''); + const [passwordSuccess, setPasswordSuccess] = useState(''); + const [passwordLoading, setPasswordLoading] = useState(false); + useEffect(() => { if (!authenticated || isSelf || !username) return; @@ -100,6 +110,42 @@ export default function UserProfile({ username, onClose, onVisitIsland }) { setFriends(res?.friends || []); }; + const handleChangePassword = async (e) => { + e.preventDefault(); + setPasswordError(''); + setPasswordSuccess(''); + + if (newPassword.length < 8) { + setPasswordError('Password must be at least 8 characters'); + return; + } + if (newPassword !== confirmPassword) { + setPasswordError('Passwords do not match'); + return; + } + + setPasswordLoading(true); + const res = await changePassword(oldPassword, newPassword); + setPasswordLoading(false); + + if (res.ok) { + setPasswordSuccess('Password changed successfully!'); + setOldPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setTimeout(() => { + setShowPasswordForm(false); + setPasswordSuccess(''); + }, 2000); + } else { + if (res.error === 'invalid_old_password') { + setPasswordError('Current password is incorrect'); + } else { + setPasswordError(res.error || 'Failed to change password'); + } + } + }; + const handleAvatarChange = async (e) => { const file = e.target.files && e.target.files[0]; if (!file) return; @@ -250,18 +296,82 @@ export default function UserProfile({ username, onClose, onVisitIsland }) { )} {isSelf && ( - + <> + + + )}
+ {isSelf && showPasswordForm && ( +
+
+
+ + setOldPassword(e.target.value)} + placeholder="Enter current password" + /> +
+
+ + setNewPassword(e.target.value)} + placeholder="Enter new password (min 8 chars)" + required + /> +
+
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm new password" + required + /> +
+ {passwordError && ( +
{passwordError}
+ )} + {passwordSuccess && ( +
{passwordSuccess}
+ )} + +
+
+ )} + {isSelf && showFriends && (
{friends.length === 0 ? ( @@ -280,6 +390,7 @@ export default function UserProfile({ username, onClose, onVisitIsland }) {

Seed: {island.seed}

+

v2026.01.10b

) : ( diff --git a/src/styles/island.css b/src/styles/island.css index 1fbfa06..3ab5972 100644 --- a/src/styles/island.css +++ b/src/styles/island.css @@ -510,3 +510,181 @@ body[data-theme="light"] .island-viewer-close { body[data-theme="light"] .island-viewer-close:hover { background: rgba(30, 58, 138, 0.3); } + +/* Password Form */ +.island-password-form { + margin-top: 0.75rem; + padding: 0.75rem; + border-radius: 12px; + background: rgba(15, 23, 42, 0.5); + border: 1px solid rgba(148, 163, 184, 0.2); +} + +.island-password-form .island-profile-row { + margin-bottom: 0.5rem; +} + +.island-password-form .island-profile-row label { + min-width: 100px; +} + +.island-profile-btn-primary { + background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%); + border-color: rgba(99, 102, 241, 0.6); + box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3); +} + +.island-profile-btn-primary:hover:not(:disabled) { + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.5); +} + +body[data-theme="light"] .island-password-form { + background: rgba(255, 255, 255, 0.5); +} + +body[data-theme="light"] .island-profile-btn-primary { + background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); +} + +/* Password Change Modal */ +.password-modal-backdrop { + position: fixed; + inset: 0; + z-index: 10000; + background: rgba(0, 0, 0, 0.75); + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); + animation: fadeIn 0.2s ease; +} + +.password-modal { + position: relative; + width: 90%; + max-width: 380px; + background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%); + border-radius: 16px; + padding: 1.5rem; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); + border: 1px solid rgba(56, 189, 248, 0.2); + animation: slideUp 0.25s ease; +} + +.password-modal-close { + position: absolute; + top: 0.75rem; + right: 0.75rem; + width: 32px; + height: 32px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + border: none; + color: white; + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.password-modal-close:hover { + background: rgba(255, 255, 255, 0.2); +} + +.password-modal-title { + color: #f1f5f9; + font-size: 1.25rem; + margin: 0 0 1.25rem 0; + font-weight: 700; +} + +.password-modal-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.password-modal-field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.password-modal-field label { + color: rgba(226, 232, 240, 0.8); + font-size: 0.8rem; + font-weight: 600; +} + +.password-modal-field input { + background: rgba(15, 23, 42, 0.6); + border: 1px solid rgba(148, 163, 184, 0.3); + color: #e2e8f0; + border-radius: 10px; + padding: 0.6rem 0.75rem; + font-size: 0.9rem; +} + +.password-modal-field input:focus { + outline: none; + border-color: rgba(56, 189, 248, 0.5); +} + +.password-modal-error { + color: #f87171; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.2); + padding: 0.5rem 0.75rem; + border-radius: 8px; + font-size: 0.8rem; +} + +.password-modal-success { + color: #86efac; + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(34, 197, 94, 0.2); + padding: 0.5rem 0.75rem; + border-radius: 8px; + font-size: 0.8rem; +} + +.password-modal-submit { + background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%); + border: none; + color: white; + padding: 0.75rem 1rem; + border-radius: 10px; + font-size: 0.95rem; + font-weight: 700; + cursor: pointer; + margin-top: 0.5rem; +} + +.password-modal-submit:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.password-modal-submit:hover:not(:disabled) { + box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4); +} + +body[data-theme="light"] .password-modal { + background: linear-gradient(135deg, #e0e7ff 0%, #f5f3ff 100%); + border-color: rgba(99, 102, 241, 0.3); +} + +body[data-theme="light"] .password-modal-title { + color: #1e3a8a; +} + +body[data-theme="light"] .password-modal-field label { + color: rgba(30, 58, 138, 0.8); +} + +body[data-theme="light"] .password-modal-field input { + background: rgba(255, 255, 255, 0.8); + color: #1e293b; + border-color: rgba(99, 102, 241, 0.3); +} diff --git a/src/styles/profile.css b/src/styles/profile.css index bef103e..f97814e 100644 --- a/src/styles/profile.css +++ b/src/styles/profile.css @@ -240,6 +240,12 @@ body[data-theme="blue"] .user-profile-container{ margin: 0; } +.user-profile-version { + color: rgba(255, 255, 255, 0.3); + font-size: 0.7rem; + margin: 4px 0 0 0; +} + /* Friends list in profile */ .user-profile-friends { margin-top: 0.75rem; @@ -382,3 +388,76 @@ body[data-theme="light"] .user-profile-close:hover { body[data-theme="light"] .user-profile-footer { border-top-color: rgba(30, 58, 138, 0.1); } + +/* Password Change Form */ +.user-profile-password-form { + margin-top: 0.75rem; + padding: 1rem; + background: rgba(0, 0, 0, 0.2); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.user-profile-form-group { + margin-bottom: 0.75rem; +} + +.user-profile-form-group label { + display: block; + color: rgba(255, 255, 255, 0.8); + font-size: 0.8rem; + font-weight: 600; + margin-bottom: 0.35rem; +} + +.user-profile-form-group .user-profile-input { + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 0.85rem; +} + +.user-profile-error { + color: #fca5a5; + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + padding: 0.5rem 0.75rem; + border-radius: 8px; + font-size: 0.8rem; + font-weight: 600; + margin-bottom: 0.75rem; +} + +.user-profile-success { + color: #86efac; + background: rgba(34, 197, 94, 0.15); + border: 1px solid rgba(34, 197, 94, 0.3); + padding: 0.5rem 0.75rem; + border-radius: 8px; + font-size: 0.8rem; + font-weight: 600; + margin-bottom: 0.75rem; +} + +.user-profile-password-form .user-profile-btn-primary { + width: 100%; + justify-content: center; + margin-top: 0.5rem; +} + +body[data-theme="light"] .user-profile-password-form { + background: rgba(255, 255, 255, 0.5); +} + +body[data-theme="light"] .user-profile-form-group label { + color: rgba(30, 58, 138, 0.8); +} + +body[data-theme="light"] .user-profile-error { + color: #dc2626; + background: rgba(239, 68, 68, 0.1); +} + +body[data-theme="light"] .user-profile-success { + color: #16a34a; + background: rgba(34, 197, 94, 0.1); +}