Add asset privacy controls for post images
This commit is contained in:
parent
d06819181e
commit
1fcff38e56
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue