Add post visibility controls and island profile avatar

This commit is contained in:
Your Name 2025-12-25 23:29:52 -05:00
parent 1fcff38e56
commit 80b266ca56
6 changed files with 409 additions and 2 deletions

View File

@ -229,6 +229,24 @@ export async function fetchPostById(postId) {
} }
} }
export async function updatePostVisibility(postId, payload = {}, token) {
if (!postId) throw new Error("post_id required");
const headers = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
const body = JSON.stringify({ post_id: postId, ...payload });
const res = await fetch(`${API_BASE}/post/visibility`, {
method: "PATCH",
headers,
credentials: "include",
body,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `HTTP ${res.status}`);
}
return res.json().catch(() => ({}));
}
/** /**

View File

@ -1,7 +1,9 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import maplibregl from 'maplibre-gl'; import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import '../../styles/island.css'; import '../../styles/island.css';
import { uploadProfileAvatar } from '../../api/client';
import { useAuth } from '../../auth/AuthContext';
/** /**
* IslandViewer: 2.5D immersive island visualization using MapLibre * IslandViewer: 2.5D immersive island visualization using MapLibre
@ -11,6 +13,12 @@ export default function IslandViewer({ island, onClose }) {
const containerRef = useRef(null); const containerRef = useRef(null);
const mapRef = useRef(null); const mapRef = useRef(null);
const animationRef = useRef(null); const animationRef = useRef(null);
const fileRef = useRef(null);
const { authenticated, username, setAvatarUrl } = useAuth();
const [uploading, setUploading] = useState(false);
const [avatarVisibility, setAvatarVisibility] = useState("public");
const [avatarGroupId, setAvatarGroupId] = useState("");
const [avatarUsers, setAvatarUsers] = useState("");
useEffect(() => { useEffect(() => {
if (!island || !containerRef.current) return; if (!island || !containerRef.current) return;
@ -131,6 +139,9 @@ export default function IslandViewer({ island, onClose }) {
}, [island]); }, [island]);
if (!island) return null; if (!island) return null;
const isSelf =
authenticated && username && island.username &&
username.toLowerCase() === island.username.toLowerCase();
return ( return (
<div className="island-viewer-modal" onClick={onClose}> <div className="island-viewer-modal" onClick={onClose}>
@ -142,6 +153,91 @@ export default function IslandViewer({ island, onClose }) {
<p>{island.bio || 'Welcome to my island'}</p> <p>{island.bio || 'Welcome to my island'}</p>
</div> </div>
<div className="island-profile-panel">
<div className="island-profile-avatar">
{island.avatar_url ? (
<img src={island.avatar_url} alt={island.username} />
) : (
<div className="island-profile-letter">
{(island.username || "U")[0].toUpperCase()}
</div>
)}
</div>
<div className="island-profile-meta">
<div className="island-profile-name">@{island.username}</div>
{isSelf ? (
<div className="island-profile-actions">
<div className="island-profile-row">
<label>Photo visibility</label>
<select
value={avatarVisibility}
onChange={(e) => {
const next = e.target.value;
setAvatarVisibility(next);
if (next !== "group") setAvatarGroupId("");
if (next !== "custom") setAvatarUsers("");
}}
>
<option value="public">Public</option>
<option value="private">Private</option>
<option value="group">Group</option>
<option value="custom">Custom</option>
</select>
</div>
{avatarVisibility === "group" ? (
<input
className="island-profile-input"
placeholder="Group ID"
value={avatarGroupId}
onChange={(e) => setAvatarGroupId(e.target.value)}
/>
) : null}
{avatarVisibility === "custom" ? (
<input
className="island-profile-input"
placeholder="Usernames (comma separated)"
value={avatarUsers}
onChange={(e) => setAvatarUsers(e.target.value)}
/>
) : null}
<button
type="button"
className="island-profile-btn"
disabled={uploading}
onClick={() => fileRef.current && fileRef.current.click()}
>
{uploading ? "Uploading..." : "Change photo"}
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
setUploading(true);
const users = avatarUsers
.split(",")
.map((u) => u.trim())
.filter(Boolean);
const res = await uploadProfileAvatar(file, {
visibility: avatarVisibility,
group_id: avatarGroupId.trim(),
users,
});
if (res && res.ok && res.avatar_url) {
island.avatar_url = res.avatar_url;
if (setAvatarUrl) setAvatarUrl(res.avatar_url);
}
setUploading(false);
}}
/>
</div>
) : null}
</div>
</div>
<div <div
ref={containerRef} ref={containerRef}
className="island-viewer-canvas" className="island-viewer-canvas"
@ -402,4 +498,3 @@ function seededRandom(seed) {
return s / 233280; return s / 233280;
}; };
} }

View File

@ -121,6 +121,9 @@ export default function MapView({
const [draftImageFile, setDraftImageFile] = useState(null); const [draftImageFile, setDraftImageFile] = useState(null);
const [draftImagePreview, setDraftImagePreview] = useState(""); const [draftImagePreview, setDraftImagePreview] = useState("");
const [useCustomImage, setUseCustomImage] = useState(false); const [useCustomImage, setUseCustomImage] = useState(false);
const [postVisibility, setPostVisibility] = useState("public");
const [postGroupId, setPostGroupId] = useState("");
const [postUsers, setPostUsers] = useState("");
const [imageVisibility, setImageVisibility] = useState("public"); const [imageVisibility, setImageVisibility] = useState("public");
const [imageGroupId, setImageGroupId] = useState(""); const [imageGroupId, setImageGroupId] = useState("");
const [imageUsers, setImageUsers] = useState(""); const [imageUsers, setImageUsers] = useState("");
@ -496,6 +499,9 @@ export default function MapView({
setDraftImageFile(null); setDraftImageFile(null);
setDraftImagePreview(""); setDraftImagePreview("");
setUseCustomImage(false); setUseCustomImage(false);
setPostVisibility("public");
setPostGroupId("");
setPostUsers("");
setImageVisibility("public"); setImageVisibility("public");
setImageGroupId(""); setImageGroupId("");
setImageUsers(""); setImageUsers("");
@ -640,6 +646,12 @@ export default function MapView({
lat, lat,
lon: lng, lon: lng,
author: authorName, author: authorName,
visibility: postVisibility,
group_id: postGroupId.trim(),
users: postUsers
.split(",")
.map((u) => u.trim())
.filter(Boolean),
}; };
if (useCustomImage && draftImage.trim()) { if (useCustomImage && draftImage.trim()) {
@ -816,6 +828,40 @@ export default function MapView({
onChange={(e) => setDraftBody(e.target.value)} onChange={(e) => setDraftBody(e.target.value)}
/> />
<div className="create-privacy-row">
<label>Post visibility</label>
<select
value={postVisibility}
onChange={(e) => {
const next = e.target.value;
setPostVisibility(next);
if (next !== "group") setPostGroupId("");
if (next !== "custom") setPostUsers("");
}}
>
<option value="public">Public</option>
<option value="private">Private</option>
<option value="group">Group</option>
<option value="custom">Custom</option>
</select>
</div>
{postVisibility === "group" ? (
<input
className="create-input"
placeholder="Group ID"
value={postGroupId}
onChange={(e) => setPostGroupId(e.target.value)}
/>
) : null}
{postVisibility === "custom" ? (
<input
className="create-input"
placeholder="Usernames (comma separated)"
value={postUsers}
onChange={(e) => setPostUsers(e.target.value)}
/>
) : null}
<div className="create-image-option"> <div className="create-image-option">
<label style={{ display: "flex", alignItems: "center", gap: "8px", cursor: "pointer" }}> <label style={{ display: "flex", alignItems: "center", gap: "8px", cursor: "pointer" }}>
<input <input

View File

@ -4,7 +4,9 @@ import {
fetchPostLikeState, fetchPostLikeState,
recordPostShare, recordPostShare,
togglePostLike, togglePostLike,
updatePostVisibility,
} from "../../api/client"; } from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
function isAuthed() { function isAuthed() {
try { try {
@ -105,11 +107,23 @@ export default function PostCard({ post, selected, onSelect }) {
const [liked, setLiked] = useState(false); const [liked, setLiked] = useState(false);
const [authed, setAuthed] = useState(isAuthed()); const [authed, setAuthed] = useState(isAuthed());
const [imgFailed, setImgFailed] = useState(false); const [imgFailed, setImgFailed] = useState(false);
const [showPrivacy, setShowPrivacy] = useState(false);
const [privacySaving, setPrivacySaving] = useState(false);
const [privacyError, setPrivacyError] = useState("");
const [postVisibility, setPostVisibility] = useState(
(post?.visibility || "public").toString()
);
const [postGroupId, setPostGroupId] = useState(
(post?.group_id || "").toString()
);
const [postUsers, setPostUsers] = useState("");
const [applyToImages, setApplyToImages] = useState(true);
const [counts, setCounts] = useState(() => ({ const [counts, setCounts] = useState(() => ({
like: post?.like_count ?? 0, like: post?.like_count ?? 0,
comment: post?.comment_count ?? 0, comment: post?.comment_count ?? 0,
share: post?.share_count ?? 0, share: post?.share_count ?? 0,
})); }));
const { username, token } = useAuth();
const postId = post?.id ?? post?._id ?? 0; const postId = post?.id ?? post?._id ?? 0;
@ -142,12 +156,18 @@ export default function PostCard({ post, selected, onSelect }) {
share: post?.share_count ?? 0, share: post?.share_count ?? 0,
}); });
}, [post?.like_count, post?.comment_count, post?.share_count]); }, [post?.like_count, post?.comment_count, post?.share_count]);
useEffect(() => {
setPostVisibility((post?.visibility || "public").toString());
setPostGroupId((post?.group_id || "").toString());
}, [post?.visibility, post?.group_id]);
const rawImg = post?.image_large || post?.image_small || post?.image || post?.media_url || ""; const rawImg = post?.image_large || post?.image_small || post?.image || post?.media_url || "";
const img = normalizeImageUrl(rawImg); const img = normalizeImageUrl(rawImg);
const title = post?.title || "Untitled"; const title = post?.title || "Untitled";
const snippet = post?.snippet || ""; const snippet = post?.snippet || "";
const author = (post?.author || post?.Author || "anon").toString(); const author = (post?.author || post?.Author || "anon").toString();
const isAuthor =
username && author && author.toLowerCase() === username.toLowerCase();
const sub = (post?.sub_category || post?.subCategory || "").toString(); const sub = (post?.sub_category || post?.subCategory || "").toString();
const timeAgo = formatRelativeTime(post?.created_at || post?.CreatedAt || post?.createdAt); const timeAgo = formatRelativeTime(post?.created_at || post?.CreatedAt || post?.createdAt);
const cat = categoryLabel(post); const cat = categoryLabel(post);
@ -264,7 +284,98 @@ export default function PostCard({ post, selected, onSelect }) {
> >
<i className="fa-solid fa-share-nodes" /> Share <i className="fa-solid fa-share-nodes" /> Share
</button> </button>
{isAuthor ? (
<button
type="button"
className={"sw-wall-btn" + (showPrivacy ? " is-active" : "")}
onClick={(e) => {
e.stopPropagation();
setShowPrivacy((prev) => !prev);
}}
>
<i className="fa-solid fa-lock" /> Visibility
</button>
) : null}
</div> </div>
{isAuthor && showPrivacy ? (
<div className="sw-wall-privacy" onClick={(e) => e.stopPropagation()}>
<div className="sw-wall-privacy-row">
<label>Post</label>
<select
value={postVisibility}
onChange={(e) => {
const next = e.target.value;
setPostVisibility(next);
if (next !== "group") setPostGroupId("");
if (next !== "custom") setPostUsers("");
}}
>
<option value="public">Public</option>
<option value="private">Private</option>
<option value="group">Group</option>
<option value="custom">Custom</option>
</select>
</div>
{postVisibility === "group" ? (
<input
className="sw-wall-privacy-input"
placeholder="Group ID"
value={postGroupId}
onChange={(e) => setPostGroupId(e.target.value)}
/>
) : null}
{postVisibility === "custom" ? (
<input
className="sw-wall-privacy-input"
placeholder="Usernames (comma separated)"
value={postUsers}
onChange={(e) => setPostUsers(e.target.value)}
/>
) : null}
<label className="sw-wall-privacy-check">
<input
type="checkbox"
checked={applyToImages}
onChange={(e) => setApplyToImages(e.target.checked)}
/>
Apply same visibility to images
</label>
{privacyError ? <div className="sw-wall-privacy-error">{privacyError}</div> : null}
<button
type="button"
className="sw-wall-btn"
disabled={privacySaving}
onClick={async () => {
setPrivacyError("");
setPrivacySaving(true);
try {
const payload = {
visibility: postVisibility,
group_id: postGroupId.trim(),
users: postUsers
.split(",")
.map((u) => u.trim())
.filter(Boolean),
};
if (applyToImages) {
payload.image_visibility = postVisibility;
payload.image_group_id = postGroupId.trim();
payload.image_users = payload.users;
}
await updatePostVisibility(postId, payload, token);
setPrivacySaving(false);
} catch (err) {
setPrivacyError("Failed to update visibility.");
setPrivacySaving(false);
}
}}
>
{privacySaving ? "Saving..." : "Save"}
</button>
</div>
) : null}
</article> </article>
); );
} }

View File

@ -128,6 +128,94 @@
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
} }
/* Profile panel */
.island-profile-panel {
display: flex;
gap: 1rem;
align-items: center;
padding: 0.75rem 1rem;
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(15, 23, 42, 0.6);
margin-bottom: 1.5rem;
}
.island-profile-avatar {
width: 64px;
height: 64px;
border-radius: 999px;
overflow: hidden;
border: 1px solid rgba(56, 189, 248, 0.6);
background: rgba(56, 189, 248, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.island-profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.island-profile-letter {
color: #e2f2ff;
font-weight: 800;
font-size: 1.4rem;
}
.island-profile-meta {
flex: 1;
min-width: 0;
}
.island-profile-name {
font-size: 0.95rem;
font-weight: 800;
color: #e2e8f0;
margin-bottom: 0.35rem;
}
.island-profile-actions {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.island-profile-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: #cbd5f5;
}
.island-profile-row select {
flex: 1;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.4);
color: #e2e8f0;
border-radius: 999px;
padding: 0.2rem 0.6rem;
font-size: 0.72rem;
}
.island-profile-input {
width: 100%;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.4);
color: #e2e8f0;
border-radius: 10px;
padding: 0.35rem 0.6rem;
font-size: 0.72rem;
}
.island-profile-btn {
align-self: flex-start;
border: 1px solid rgba(56, 189, 248, 0.6);
background: rgba(56, 189, 248, 0.2);
color: #e2f2ff;
font-weight: 800;
font-size: 0.72rem;
padding: 0.35rem 0.8rem;
border-radius: 999px;
cursor: pointer;
}
.island-profile-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Canvas */ /* Canvas */
.island-viewer-canvas { .island-viewer-canvas {

View File

@ -169,6 +169,55 @@ body[data-theme="light"] .sw-wall-image-fallback{
gap:.4rem; gap:.4rem;
flex-wrap:wrap; flex-wrap:wrap;
} }
.sw-wall-privacy{
margin-top:.25rem;
padding:.5rem .6rem;
border-radius:12px;
border:1px solid rgba(148,163,184,0.35);
background: rgba(15,23,42,0.75);
display:flex;
flex-direction:column;
gap:.4rem;
}
.sw-wall-privacy-row{
display:flex;
align-items:center;
gap:.5rem;
font-size:.72rem;
color:#cbd5f5;
}
.sw-wall-privacy-row label{
white-space:nowrap;
}
.sw-wall-privacy-row select{
flex:1;
background:#020617;
border-radius:999px;
border:1px solid #4b5563;
padding:.25rem .6rem;
font-size:.72rem;
color:#e5e7eb;
}
.sw-wall-privacy-input{
width:100%;
border-radius:999px;
border:1px solid #4b5563;
padding:.3rem .6rem;
background:#020617;
color:#e5e7eb;
font-size:.72rem;
}
.sw-wall-privacy-check{
display:flex;
align-items:center;
gap:.45rem;
font-size:.7rem;
color:#cbd5f5;
}
.sw-wall-privacy-error{
font-size:.7rem;
color:#fca5a5;
}
.sw-wall-sentinel{ .sw-wall-sentinel{
height: 1px; height: 1px;