Add asset privacy controls for post images

This commit is contained in:
Your Name 2025-12-25 22:37:10 -05:00
parent d06819181e
commit 1fcff38e56
5 changed files with 252 additions and 13 deletions

View File

@ -17,6 +17,71 @@ function authHeaders() {
} }
} }
function isAssetRef(value) {
return typeof value === "string" && value.startsWith("asset://");
}
async function resolveAssetURLs(ids) {
if (!ids || !ids.length) return {};
const qs = new URLSearchParams();
qs.set("ids", ids.join(","));
try {
const res = await fetch(`${API_BASE}/assets/resolve?${qs.toString()}`, {
credentials: "include",
headers: { ...authHeaders() },
});
if (!res.ok) return {};
const data = await res.json();
return (data && data.urls) || {};
} catch (err) {
console.warn("resolveAssetURLs error:", err);
return {};
}
}
async function resolveAssetRefsInPosts(posts) {
if (!Array.isArray(posts) || posts.length === 0) return posts;
const ids = [];
for (const p of posts) {
[p?.image_small, p?.image_large, p?.image, p?.media_url].forEach((v) => {
if (isAssetRef(v)) ids.push(v.replace("asset://", ""));
});
}
const uniq = Array.from(new Set(ids));
if (!uniq.length) return posts;
const resolved = await resolveAssetURLs(uniq);
return posts.map((p) => {
const clone = { ...p };
["image_small", "image_large", "image", "media_url"].forEach((k) => {
const v = clone[k];
if (isAssetRef(v)) {
const id = v.replace("asset://", "");
if (resolved[id]) clone[k] = resolved[id];
}
});
return clone;
});
}
async function resolveAssetRefsInIslands(islands) {
if (!Array.isArray(islands) || islands.length === 0) return islands;
const ids = [];
for (const i of islands) {
if (isAssetRef(i?.avatar_url)) ids.push(i.avatar_url.replace("asset://", ""));
}
const uniq = Array.from(new Set(ids));
if (!uniq.length) return islands;
const resolved = await resolveAssetURLs(uniq);
return islands.map((i) => {
if (isAssetRef(i?.avatar_url)) {
const id = i.avatar_url.replace("asset://", "");
if (resolved[id]) return { ...i, avatar_url: resolved[id] };
}
return i;
});
}
/** /**
* Récupère la liste des posts, avec filtres optionnels. * Récupère la liste des posts, avec filtres optionnels.
*/ */
@ -45,7 +110,8 @@ export async function fetchPosts(filters = {}) {
} }
const data = await res.json(); const data = await res.json();
return Array.isArray(data) ? data : data.items || []; const items = Array.isArray(data) ? data : data.items || [];
return await resolveAssetRefsInPosts(items);
} }
/** /**
@ -155,7 +221,9 @@ export async function fetchPostById(postId) {
}); });
if (!res.ok) return null; if (!res.ok) return null;
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
return data && typeof data === "object" ? data : null; if (!data || typeof data !== "object") return null;
const resolved = await resolveAssetRefsInPosts([data]);
return resolved && resolved[0] ? resolved[0] : data;
} catch { } catch {
return null; return null;
} }
@ -219,7 +287,8 @@ export async function fetchSmartFeed(params = {}) {
return []; return [];
} }
const data = await res.json(); const data = await res.json();
return Array.isArray(data) ? data : data.results || data.items || []; const items = Array.isArray(data) ? data : data.results || data.items || [];
return await resolveAssetRefsInPosts(items);
} catch (err) { } catch (err) {
console.warn("fetchSmartFeed error:", err); console.warn("fetchSmartFeed error:", err);
return []; return [];
@ -281,7 +350,12 @@ export async function pollNewPosts(params = {}) {
console.warn("pollNewPosts failed", res.status); console.warn("pollNewPosts failed", res.status);
return { posts: [], count: 0, max_id: 0 }; return { posts: [], count: 0, max_id: 0 };
} }
return await res.json(); const data = await res.json();
if (data && data.avatar_url && isAssetRef(data.avatar_url)) {
const resolved = await resolveAssetRefsInIslands([data]);
return resolved[0] || data;
}
return data;
} catch (err) { } catch (err) {
console.warn("pollNewPosts error:", err); console.warn("pollNewPosts error:", err);
return { posts: [], count: 0, max_id: 0 }; return { posts: [], count: 0, max_id: 0 };
@ -312,7 +386,8 @@ export async function fetchIslands(params = {}) {
return []; return [];
} }
const data = await res.json(); const data = await res.json();
return Array.isArray(data) ? data : data.islands || []; const items = Array.isArray(data) ? data : data.islands || [];
return await resolveAssetRefsInIslands(items);
} catch (err) { } catch (err) {
console.warn("fetchIslands error:", err); console.warn("fetchIslands error:", err);
return []; return [];
@ -335,7 +410,12 @@ export async function fetchIsland(islandId) {
console.warn("fetchIsland failed", res.status); console.warn("fetchIsland failed", res.status);
return null; return null;
} }
return await res.json(); const data = await res.json();
if (data && data.avatar_url && isAssetRef(data.avatar_url)) {
const resolved = await resolveAssetRefsInIslands([data]);
return resolved[0] || data;
}
return data;
} catch (err) { } catch (err) {
console.warn("fetchIsland error:", err); console.warn("fetchIsland error:", err);
return null; return null;
@ -351,18 +431,26 @@ export async function fetchIslandByUsername(username) {
console.warn("fetchIslandByUsername failed", res.status); console.warn("fetchIslandByUsername failed", res.status);
return null; return null;
} }
return await res.json(); const data = await res.json();
if (data && data.avatar_url && isAssetRef(data.avatar_url)) {
const resolved = await resolveAssetRefsInIslands([data]);
return resolved[0] || data;
}
return data;
} catch (err) { } catch (err) {
console.warn("fetchIslandByUsername error:", err); console.warn("fetchIslandByUsername error:", err);
return null; return null;
} }
} }
export async function uploadProfileAvatar(file) { export async function uploadProfileAvatar(file, opts = {}) {
if (!file) return { ok: false }; if (!file) return { ok: false };
const url = `${API_BASE}/profile/avatar`; const url = `${API_BASE}/profile/avatar`;
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);
if (opts.visibility) form.append("visibility", opts.visibility);
if (opts.group_id) form.append("group_id", opts.group_id);
if (opts.users && opts.users.length) form.append("users", opts.users.join(","));
try { try {
const res = await fetch(url, { const res = await fetch(url, {
method: "POST", method: "POST",

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 [imageVisibility, setImageVisibility] = useState("public");
const [imageGroupId, setImageGroupId] = useState("");
const [imageUsers, setImageUsers] = useState("");
const [uploadingImage, setUploadingImage] = useState(false); const [uploadingImage, setUploadingImage] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState(""); const [saveError, setSaveError] = useState("");
@ -493,6 +496,9 @@ export default function MapView({
setDraftImageFile(null); setDraftImageFile(null);
setDraftImagePreview(""); setDraftImagePreview("");
setUseCustomImage(false); setUseCustomImage(false);
setImageVisibility("public");
setImageGroupId("");
setImageUsers("");
const main = mainFilter === "All" ? "News" : mainFilter; const main = mainFilter === "All" ? "News" : mainFilter;
setDraftCategory(main); setDraftCategory(main);
const list = CREATE_CATEGORY_MAP[main] || CREATE_CATEGORY_MAP.News; const list = CREATE_CATEGORY_MAP[main] || CREATE_CATEGORY_MAP.News;
@ -539,9 +545,12 @@ export default function MapView({
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('image', file); formData.append('file', file);
formData.append('visibility', imageVisibility);
if (imageGroupId.trim()) formData.append('group_id', imageGroupId.trim());
if (imageUsers.trim()) formData.append('users', imageUsers.trim());
const res = await fetch('/api/upload-image', { const res = await fetch('/api/assets/upload', {
method: 'POST', method: 'POST',
body: formData, body: formData,
credentials: 'include', credentials: 'include',
@ -553,7 +562,11 @@ export default function MapView({
} }
const data = await res.json(); const data = await res.json();
setDraftImage(data.url || data.image_url); if (data && data.url) {
setDraftImage(data.url);
} else if (data && data.asset_id) {
setDraftImage(`asset://${data.asset_id}`);
}
setUploadingImage(false); setUploadingImage(false);
} catch (err) { } catch (err) {
console.error('Image upload error:', err); console.error('Image upload error:', err);
@ -815,7 +828,7 @@ export default function MapView({
</div> </div>
{useCustomImage && ( {useCustomImage && (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}> <div className="create-image-panel">
<div style={{ display: "flex", gap: "8px" }}> <div style={{ display: "flex", gap: "8px" }}>
<input <input
type="file" type="file"
@ -862,6 +875,40 @@ export default function MapView({
}} }}
disabled={!!draftImageFile} disabled={!!draftImageFile}
/> />
<div className="create-privacy-row">
<label>Image visibility</label>
<select
value={imageVisibility}
onChange={(e) => {
const next = e.target.value;
setImageVisibility(next);
if (next !== "group") setImageGroupId("");
if (next !== "custom") setImageUsers("");
}}
>
<option value="public">Public</option>
<option value="private">Private</option>
<option value="group">Group</option>
<option value="custom">Custom</option>
</select>
</div>
{imageVisibility === "group" ? (
<input
className="create-input"
placeholder="Group ID"
value={imageGroupId}
onChange={(e) => setImageGroupId(e.target.value)}
/>
) : null}
{imageVisibility === "custom" ? (
<input
className="create-input"
placeholder="Usernames (comma separated)"
value={imageUsers}
onChange={(e) => setImageUsers(e.target.value)}
/>
) : null}
</div> </div>
)} )}

View File

@ -16,6 +16,9 @@ export default function UserProfile({ username, onClose, onVisitIsland }) {
const [island, setIsland] = useState(null); const [island, setIsland] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [avatarVisibility, setAvatarVisibility] = useState("public");
const [avatarGroupId, setAvatarGroupId] = useState("");
const [avatarUsers, setAvatarUsers] = useState("");
const fileRef = useRef(null); const fileRef = useRef(null);
const isSelf = authenticated && authedUser && authedUser.toLowerCase() === username.toLowerCase(); const isSelf = authenticated && authedUser && authedUser.toLowerCase() === username.toLowerCase();
@ -48,7 +51,15 @@ export default function UserProfile({ username, onClose, onVisitIsland }) {
const file = e.target.files && e.target.files[0]; const file = e.target.files && e.target.files[0];
if (!file) return; if (!file) return;
setUploading(true); setUploading(true);
const res = await uploadProfileAvatar(file); 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) { if (res && res.ok && res.avatar_url) {
setIsland(prev => (prev ? { ...prev, avatar_url: res.avatar_url } : prev)); setIsland(prev => (prev ? { ...prev, avatar_url: res.avatar_url } : prev));
if (setAvatarUrl) setAvatarUrl(res.avatar_url); if (setAvatarUrl) setAvatarUrl(res.avatar_url);
@ -77,6 +88,34 @@ export default function UserProfile({ username, onClose, onVisitIsland }) {
)} )}
{isSelf ? ( {isSelf ? (
<div className="user-profile-avatar-actions"> <div className="user-profile-avatar-actions">
<div className="user-profile-privacy-row">
<label>Visibility</label>
<select
value={avatarVisibility}
onChange={(e) => setAvatarVisibility(e.target.value)}
>
<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="user-profile-input"
placeholder="Group ID"
value={avatarGroupId}
onChange={(e) => setAvatarGroupId(e.target.value)}
/>
) : null}
{avatarVisibility === "custom" ? (
<input
className="user-profile-input"
placeholder="Usernames (comma separated)"
value={avatarUsers}
onChange={(e) => setAvatarUsers(e.target.value)}
/>
) : null}
<button <button
className="user-profile-btn user-profile-btn-ghost" className="user-profile-btn user-profile-btn-ghost"
type="button" type="button"

View File

@ -210,6 +210,32 @@
resize:none; resize:none;
font-size:.78rem; font-size:.78rem;
} }
.create-image-panel{
display:flex;
flex-direction:column;
gap:.4rem;
margin-top:.35rem;
}
.create-privacy-row{
display:flex;
align-items:center;
gap:.5rem;
font-size:.7rem;
color:#e5e7eb;
}
.create-privacy-row label{
white-space:nowrap;
color:#cbd5f5;
}
.create-privacy-row select{
flex:1;
background:#020617;
border-radius:999px;
border:1px solid #4b5563;
padding:.3rem .6rem;
font-size:.72rem;
color:#e5e7eb;
}
.create-error{ .create-error{
margin-top:.25rem; margin-top:.25rem;
font-size:.7rem; font-size:.7rem;

View File

@ -96,6 +96,32 @@
.user-profile-avatar-actions{ .user-profile-avatar-actions{
margin-top: 0.4rem; margin-top: 0.4rem;
} }
.user-profile-privacy-row{
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
font-size: 0.75rem;
color: rgba(226, 232, 240, 0.9);
}
.user-profile-privacy-row select{
background: rgba(15, 23, 42, 0.3);
border: 1px solid rgba(148,163,184,0.4);
color: #e2e8f0;
border-radius: 999px;
padding: 0.2rem 0.6rem;
font-size: 0.72rem;
}
.user-profile-input{
width: 100%;
margin-bottom: 0.4rem;
background: rgba(15, 23, 42, 0.25);
border: 1px solid rgba(148,163,184,0.35);
border-radius: 10px;
padding: 0.35rem 0.6rem;
color: #e2e8f0;
font-size: 0.72rem;
}
.user-profile-btn-ghost{ .user-profile-btn-ghost{
background: rgba(15, 23, 42, 0.2); background: rgba(15, 23, 42, 0.2);
color: #e2e8f0; color: #e2e8f0;
@ -264,6 +290,19 @@ body[data-theme="light"] .user-profile-btn-ghost{
color: #1e293b; color: #1e293b;
border-color: rgba(99, 102, 241, 0.45); border-color: rgba(99, 102, 241, 0.45);
} }
body[data-theme="light"] .user-profile-privacy-row{
color: rgba(30, 41, 59, 0.9);
}
body[data-theme="light"] .user-profile-privacy-row select{
background: rgba(255, 255, 255, 0.9);
color: #1e293b;
border-color: rgba(99, 102, 241, 0.45);
}
body[data-theme="light"] .user-profile-input{
background: rgba(255, 255, 255, 0.9);
color: #1e293b;
border-color: rgba(99, 102, 241, 0.45);
}
body[data-theme="light"] .user-profile-bio { body[data-theme="light"] .user-profile-bio {
background: rgba(255, 255, 255, 0.5); background: rgba(255, 255, 255, 0.5);