fix: use presigned S3 URLs for avatar images instead of /uploads/ paths
Avatar images were rendered via /uploads/ which doesn't exist since the S3 migration. Now the server enriches profile responses with avatarImageUrl (presigned S3 URL) and the frontend uses it directly. Also fixed the public profile page at /users/:id. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,8 @@ export function ProfileSection() {
|
||||
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [bio, setBio] = useState("");
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||
const [avatarFilename, setAvatarFilename] = useState<string | null>(null);
|
||||
const [avatarDisplayUrl, setAvatarDisplayUrl] = useState<string | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error";
|
||||
@@ -24,7 +25,8 @@ export function ProfileSection() {
|
||||
if (profile && !dirty) {
|
||||
setDisplayName(profile.displayName ?? "");
|
||||
setBio(profile.bio ?? "");
|
||||
setAvatarUrl(profile.avatarUrl ?? null);
|
||||
setAvatarFilename(profile.avatarUrl ?? null);
|
||||
setAvatarDisplayUrl(profile.avatarImageUrl ?? null);
|
||||
}
|
||||
}, [profile, dirty]);
|
||||
|
||||
@@ -34,7 +36,7 @@ export function ProfileSection() {
|
||||
try {
|
||||
await updateProfile.mutateAsync({
|
||||
displayName: displayName.trim() || undefined,
|
||||
avatarUrl,
|
||||
avatarUrl: avatarFilename,
|
||||
bio: bio.trim() || undefined,
|
||||
});
|
||||
setDirty(false);
|
||||
@@ -63,13 +65,16 @@ export function ProfileSection() {
|
||||
return;
|
||||
}
|
||||
|
||||
const localPreview = URL.createObjectURL(file);
|
||||
setAvatarDisplayUrl(localPreview);
|
||||
setUploading(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const result = await apiUpload<{ filename: string }>("/api/images", file);
|
||||
setAvatarUrl(result.filename);
|
||||
setAvatarFilename(result.filename);
|
||||
setDirty(true);
|
||||
} catch {
|
||||
setAvatarDisplayUrl(null);
|
||||
setMessage({ type: "error", text: "Avatar upload failed." });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
@@ -92,9 +97,9 @@ export function ProfileSection() {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="relative w-16 h-16 rounded-full overflow-hidden cursor-pointer group shrink-0"
|
||||
>
|
||||
{avatarUrl ? (
|
||||
{avatarDisplayUrl ? (
|
||||
<img
|
||||
src={`/uploads/${avatarUrl}`}
|
||||
src={avatarDisplayUrl}
|
||||
alt="Avatar"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
@@ -145,11 +150,12 @@ export function ProfileSection() {
|
||||
>
|
||||
{uploading ? "Uploading..." : "Change avatar"}
|
||||
</button>
|
||||
{avatarUrl && (
|
||||
{avatarFilename && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAvatarUrl(null);
|
||||
setAvatarFilename(null);
|
||||
setAvatarDisplayUrl(null);
|
||||
setDirty(true);
|
||||
}}
|
||||
className="block text-xs text-red-500 hover:text-red-700 mt-0.5"
|
||||
|
||||
@@ -5,6 +5,7 @@ interface PublicProfile {
|
||||
id: number;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
avatarImageUrl: string | null;
|
||||
bio: string | null;
|
||||
setups: { id: number; name: string; createdAt: string }[];
|
||||
}
|
||||
|
||||
@@ -52,9 +52,9 @@ function PublicProfilePage() {
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
||||
{/* Profile header */}
|
||||
<div className="flex items-center gap-5 mb-8">
|
||||
{profile.avatarUrl ? (
|
||||
{profile.avatarImageUrl ? (
|
||||
<img
|
||||
src={`/uploads/${profile.avatarUrl}`}
|
||||
src={profile.avatarImageUrl}
|
||||
alt={displayName}
|
||||
className="w-20 h-20 rounded-full object-cover"
|
||||
/>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
listApiKeys,
|
||||
} from "../services/auth.service.ts";
|
||||
import { updateProfile } from "../services/profile.service.ts";
|
||||
import { getImageUrl } from "../services/storage.service.ts";
|
||||
|
||||
type Env = { Variables: { db?: any; userId?: number } };
|
||||
|
||||
@@ -97,7 +98,12 @@ app.put(
|
||||
const data = c.req.valid("json");
|
||||
const updated = await updateProfile(db, userId, data);
|
||||
if (!updated) return c.json({ error: "User not found" }, 404);
|
||||
return c.json(updated);
|
||||
return c.json({
|
||||
...updated,
|
||||
avatarImageUrl: updated.avatarUrl
|
||||
? await getImageUrl(updated.avatarUrl)
|
||||
: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { Hono } from "hono";
|
||||
import { parseId } from "../lib/params.ts";
|
||||
import { getPublicProfile } from "../services/profile.service.ts";
|
||||
import { getImageUrl } from "../services/storage.service.ts";
|
||||
|
||||
type Env = { Variables: { db?: any; userId?: number } };
|
||||
|
||||
async function enrichAvatarUrl<T extends { avatarUrl: string | null }>(
|
||||
record: T,
|
||||
): Promise<T & { avatarImageUrl: string | null }> {
|
||||
return {
|
||||
...record,
|
||||
avatarImageUrl: record.avatarUrl
|
||||
? await getImageUrl(record.avatarUrl)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
const app = new Hono<Env>();
|
||||
|
||||
// GET /:id/profile — Public profile (no auth required)
|
||||
@@ -15,7 +27,7 @@ app.get("/:id/profile", async (c) => {
|
||||
const profile = await getPublicProfile(db, id);
|
||||
if (!profile) return c.json({ error: "User not found" }, 404);
|
||||
|
||||
return c.json(profile);
|
||||
return c.json(await enrichAvatarUrl(profile));
|
||||
});
|
||||
|
||||
export { app as profileRoutes };
|
||||
|
||||
Reference in New Issue
Block a user