Add post visibility controls and island profile avatar
This commit is contained in:
parent
1fcff38e56
commit
80b266ca56
|
|
@ -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(() => ({}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import '../../styles/island.css';
|
||||
import { uploadProfileAvatar } from '../../api/client';
|
||||
import { useAuth } from '../../auth/AuthContext';
|
||||
|
||||
/**
|
||||
* IslandViewer: 2.5D immersive island visualization using MapLibre
|
||||
|
|
@ -11,6 +13,12 @@ export default function IslandViewer({ island, onClose }) {
|
|||
const containerRef = useRef(null);
|
||||
const mapRef = 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(() => {
|
||||
if (!island || !containerRef.current) return;
|
||||
|
|
@ -131,6 +139,9 @@ export default function IslandViewer({ island, onClose }) {
|
|||
}, [island]);
|
||||
|
||||
if (!island) return null;
|
||||
const isSelf =
|
||||
authenticated && username && island.username &&
|
||||
username.toLowerCase() === island.username.toLowerCase();
|
||||
|
||||
return (
|
||||
<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>
|
||||
</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
|
||||
ref={containerRef}
|
||||
className="island-viewer-canvas"
|
||||
|
|
@ -402,4 +498,3 @@ function seededRandom(seed) {
|
|||
return s / 233280;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,6 +121,9 @@ export default function MapView({
|
|||
const [draftImageFile, setDraftImageFile] = useState(null);
|
||||
const [draftImagePreview, setDraftImagePreview] = useState("");
|
||||
const [useCustomImage, setUseCustomImage] = useState(false);
|
||||
const [postVisibility, setPostVisibility] = useState("public");
|
||||
const [postGroupId, setPostGroupId] = useState("");
|
||||
const [postUsers, setPostUsers] = useState("");
|
||||
const [imageVisibility, setImageVisibility] = useState("public");
|
||||
const [imageGroupId, setImageGroupId] = useState("");
|
||||
const [imageUsers, setImageUsers] = useState("");
|
||||
|
|
@ -496,6 +499,9 @@ export default function MapView({
|
|||
setDraftImageFile(null);
|
||||
setDraftImagePreview("");
|
||||
setUseCustomImage(false);
|
||||
setPostVisibility("public");
|
||||
setPostGroupId("");
|
||||
setPostUsers("");
|
||||
setImageVisibility("public");
|
||||
setImageGroupId("");
|
||||
setImageUsers("");
|
||||
|
|
@ -640,6 +646,12 @@ export default function MapView({
|
|||
lat,
|
||||
lon: lng,
|
||||
author: authorName,
|
||||
visibility: postVisibility,
|
||||
group_id: postGroupId.trim(),
|
||||
users: postUsers
|
||||
.split(",")
|
||||
.map((u) => u.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
|
||||
if (useCustomImage && draftImage.trim()) {
|
||||
|
|
@ -816,6 +828,40 @@ export default function MapView({
|
|||
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">
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "8px", cursor: "pointer" }}>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import {
|
|||
fetchPostLikeState,
|
||||
recordPostShare,
|
||||
togglePostLike,
|
||||
updatePostVisibility,
|
||||
} from "../../api/client";
|
||||
import { useAuth } from "../../auth/AuthContext";
|
||||
|
||||
function isAuthed() {
|
||||
try {
|
||||
|
|
@ -105,11 +107,23 @@ export default function PostCard({ post, selected, onSelect }) {
|
|||
const [liked, setLiked] = useState(false);
|
||||
const [authed, setAuthed] = useState(isAuthed());
|
||||
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(() => ({
|
||||
like: post?.like_count ?? 0,
|
||||
comment: post?.comment_count ?? 0,
|
||||
share: post?.share_count ?? 0,
|
||||
}));
|
||||
const { username, token } = useAuth();
|
||||
|
||||
const postId = post?.id ?? post?._id ?? 0;
|
||||
|
||||
|
|
@ -142,12 +156,18 @@ export default function PostCard({ post, selected, onSelect }) {
|
|||
share: post?.share_count ?? 0,
|
||||
});
|
||||
}, [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 img = normalizeImageUrl(rawImg);
|
||||
const title = post?.title || "Untitled";
|
||||
const snippet = post?.snippet || "";
|
||||
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 timeAgo = formatRelativeTime(post?.created_at || post?.CreatedAt || post?.createdAt);
|
||||
const cat = categoryLabel(post);
|
||||
|
|
@ -264,7 +284,98 @@ export default function PostCard({ post, selected, onSelect }) {
|
|||
>
|
||||
<i className="fa-solid fa-share-nodes" /> Share
|
||||
</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>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,94 @@
|
|||
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 */
|
||||
|
||||
.island-viewer-canvas {
|
||||
|
|
|
|||
|
|
@ -169,6 +169,55 @@ body[data-theme="light"] .sw-wall-image-fallback{
|
|||
gap:.4rem;
|
||||
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{
|
||||
height: 1px;
|
||||
|
|
|
|||
Loading…
Reference in New Issue