resolve relative media URLs against the API base in react-openapi
- add resolveMediaUrl() in fields/utils.ts: prefixes /uploads/... and other relative paths with the configured baseApiUrl, leaves absolute http/data/blob URLs untouched - use it for every image render: ListCellRenderer, DetailFieldRenderer, ImageField form preview, and FileUploadField preview/href (which hardcoded /uploads/<value>) - drop the Expense-side resolveLogoUrl hack and logo pre-normalization; the Expenses page now renders logos through the same react-openapi path as the admin entity list
This commit is contained in:
@@ -3,6 +3,8 @@ import { Box, Typography, Avatar } from "@mui/material";
|
|||||||
import type { FieldConfig } from "../../types";
|
import type { FieldConfig } from "../../types";
|
||||||
import { ListCellRenderer } from "./ListCellRenderer";
|
import { ListCellRenderer } from "./ListCellRenderer";
|
||||||
import { CurrencyField } from "./renderers/CurrencyField";
|
import { CurrencyField } from "./renderers/CurrencyField";
|
||||||
|
import { resolveMediaUrl } from "./utils";
|
||||||
|
import { useAppContext } from "../../context/AppContext";
|
||||||
|
|
||||||
interface DetailFieldProps {
|
interface DetailFieldProps {
|
||||||
field: FieldConfig;
|
field: FieldConfig;
|
||||||
@@ -12,6 +14,7 @@ interface DetailFieldProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DetailFieldRenderer({ field, value, displayFormat, basePath }: DetailFieldProps) {
|
export function DetailFieldRenderer({ field, value, displayFormat, basePath }: DetailFieldProps) {
|
||||||
|
const { config } = useAppContext();
|
||||||
if (field.hidden?.detail) return null;
|
if (field.hidden?.detail) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -22,7 +25,7 @@ export function DetailFieldRenderer({ field, value, displayFormat, basePath }: D
|
|||||||
{field.uiType === "currency" ? (
|
{field.uiType === "currency" ? (
|
||||||
<CurrencyField value={Number(value)} large />
|
<CurrencyField value={Number(value)} large />
|
||||||
) : field.uiType === "image" ? (
|
) : field.uiType === "image" ? (
|
||||||
<Avatar src={value} variant="rounded" sx={{ width: 120, height: 120 }} />
|
<Avatar src={resolveMediaUrl(value, config.baseApiUrl)} variant="rounded" sx={{ width: 120, height: 120 }} />
|
||||||
) : (
|
) : (
|
||||||
<ListCellRenderer field={field} value={value} displayFormat={displayFormat} basePath={basePath} />
|
<ListCellRenderer field={field} value={value} displayFormat={displayFormat} basePath={basePath} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Box, Typography, Chip, Avatar, Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid } from "@mui/material";
|
import { Box, Typography, Chip, Avatar, Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid } from "@mui/material";
|
||||||
import type { FieldConfig } from "../../types";
|
import type { FieldConfig } from "../../types";
|
||||||
import { applyDisplayFormat } from "./utils";
|
import { applyDisplayFormat, resolveMediaUrl } from "./utils";
|
||||||
import { InlineRefField } from "./renderers/InlineRefField";
|
import { InlineRefField } from "./renderers/InlineRefField";
|
||||||
import { CurrencyField } from "./renderers/CurrencyField";
|
import { CurrencyField } from "./renderers/CurrencyField";
|
||||||
import { extractFields } from "../../transformers/field-config";
|
import { extractFields } from "../../transformers/field-config";
|
||||||
@@ -17,7 +17,7 @@ interface ListCellProps {
|
|||||||
|
|
||||||
export function ListCellRenderer({ field, value, displayFormat, basePath }: ListCellProps) {
|
export function ListCellRenderer({ field, value, displayFormat, basePath }: ListCellProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { schemas } = useAppContext();
|
const { schemas, config } = useAppContext();
|
||||||
const [inlineItem, setInlineItem] = useState<any>(null);
|
const [inlineItem, setInlineItem] = useState<any>(null);
|
||||||
|
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
@@ -138,7 +138,7 @@ export function ListCellRenderer({ field, value, displayFormat, basePath }: List
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (field.uiType === "image" && value) {
|
if (field.uiType === "image" && value) {
|
||||||
return <Avatar src={value} variant="rounded" sx={{ width: 40, height: 40 }} />;
|
return <Avatar src={resolveMediaUrl(value, config.baseApiUrl)} variant="rounded" sx={{ width: 40, height: 40 }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field.uiType === "currency" && value != null && !Number.isNaN(Number(value))) {
|
if (field.uiType === "currency" && value != null && !Number.isNaN(Number(value))) {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import React from "react";
|
|||||||
import { Box, Typography, Avatar, Chip, Button, FormHelperText } from "@mui/material";
|
import { Box, Typography, Avatar, Chip, Button, FormHelperText } from "@mui/material";
|
||||||
import type { FieldConfig } from "../../../types";
|
import type { FieldConfig } from "../../../types";
|
||||||
import { getApi } from "../../../hooks/useApi";
|
import { getApi } from "../../../hooks/useApi";
|
||||||
|
import { useAppContext } from "../../../context/AppContext";
|
||||||
|
import { resolveMediaUrl } from "../utils";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
field: FieldConfig;
|
field: FieldConfig;
|
||||||
@@ -19,6 +21,7 @@ const acceptMap: Record<string, string> = {
|
|||||||
export function FileUploadField({ field, value, onChange }: Props) {
|
export function FileUploadField({ field, value, onChange }: Props) {
|
||||||
const uploadConfig = field.upload!;
|
const uploadConfig = field.upload!;
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const { config } = useAppContext();
|
||||||
|
|
||||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -51,12 +54,12 @@ export function FileUploadField({ field, value, onChange }: Props) {
|
|||||||
</Typography>
|
</Typography>
|
||||||
{value ? (
|
{value ? (
|
||||||
uploadConfig.type === "image" ? (
|
uploadConfig.type === "image" ? (
|
||||||
<Avatar src={`/uploads/${value}`} variant="rounded" sx={{ width: 120, height: 120 }} />
|
<Avatar src={resolveMediaUrl(`/uploads/${value}`, config.baseApiUrl)} variant="rounded" sx={{ width: 120, height: 120 }} />
|
||||||
) : (
|
) : (
|
||||||
<Chip
|
<Chip
|
||||||
label={value}
|
label={value}
|
||||||
component="a"
|
component="a"
|
||||||
href={`/uploads/${value}`}
|
href={resolveMediaUrl(`/uploads/${value}`, config.baseApiUrl)}
|
||||||
clickable
|
clickable
|
||||||
onDelete={handleReplace}
|
onDelete={handleReplace}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Box, Typography, Avatar, FormHelperText } from "@mui/material";
|
|||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import type { FieldConfig } from "../../../types";
|
import type { FieldConfig } from "../../../types";
|
||||||
import { getApi } from "../../../hooks/useApi";
|
import { getApi } from "../../../hooks/useApi";
|
||||||
|
import { useAppContext } from "../../../context/AppContext";
|
||||||
|
import { resolveMediaUrl } from "../utils";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
field: FieldConfig;
|
field: FieldConfig;
|
||||||
@@ -13,6 +15,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ImageField({ field, value, onChange, id, uploadUrl }: Props) {
|
export function ImageField({ field, value, onChange, id, uploadUrl }: Props) {
|
||||||
|
const { config } = useAppContext();
|
||||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -47,7 +50,7 @@ export function ImageField({ field, value, onChange, id, uploadUrl }: Props) {
|
|||||||
{field.label}
|
{field.label}
|
||||||
</Typography>
|
</Typography>
|
||||||
{value ? (
|
{value ? (
|
||||||
<Avatar src={value} variant="rounded" sx={{ width: 120, height: 120 }} />
|
<Avatar src={resolveMediaUrl(value, config.baseApiUrl)} variant="rounded" sx={{ width: 120, height: 120 }} />
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outlined" component="label" size="small">
|
<Button variant="outlined" component="label" size="small">
|
||||||
Upload {field.label}
|
Upload {field.label}
|
||||||
|
|||||||
@@ -9,3 +9,15 @@ export function applyDisplayFormat(item: any, format: string): string {
|
|||||||
return val != null ? String(val) : "";
|
return val != null ? String(val) : "";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a media/image URL against the API base so relative paths
|
||||||
|
* like `/uploads/entity_logos/x.svg` point at the backend origin.
|
||||||
|
* Absolute (http/data/blob) URLs are returned untouched.
|
||||||
|
*/
|
||||||
|
export function resolveMediaUrl(value: string | null | undefined, baseUrl?: string): string | undefined {
|
||||||
|
if (value == null || value === "") return undefined;
|
||||||
|
if (value.startsWith("http") || value.startsWith("data:") || value.startsWith("blob:")) return value;
|
||||||
|
if (value.startsWith("/") && baseUrl) return `${baseUrl.replace(/\/+$/, "")}${value}`;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ import { useResource, useAppContext, formatCurrency } from "../../react-openapi"
|
|||||||
import { PageHeader } from "../ui/PageHeader";
|
import { PageHeader } from "../ui/PageHeader";
|
||||||
import { EmptyState } from "../ui/EmptyState";
|
import { EmptyState } from "../ui/EmptyState";
|
||||||
import { ExpenseList } from "./ExpenseList";
|
import { ExpenseList } from "./ExpenseList";
|
||||||
import { ExpenseItem, ExpenseFieldConfigs, isExpense, currentMonthKey, monthKey, monthLabel, parseOccurredAt, resolveLogoUrl } from "./types";
|
import { ExpenseItem, ExpenseFieldConfigs, isExpense, currentMonthKey, monthKey, monthLabel, parseOccurredAt } from "./types";
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL;
|
|
||||||
|
|
||||||
function StatCard({ label, value, hint }: { label: string; value: string; hint?: string }) {
|
function StatCard({ label, value, hint }: { label: string; value: string; hint?: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -77,14 +75,7 @@ export default function Expense() {
|
|||||||
let mounted = true;
|
let mounted = true;
|
||||||
list({ limit: 0 }).then((res) => {
|
list({ limit: 0 }).then((res) => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
const rows = (res.items ?? []) as ExpenseItem[];
|
setItems((res.items ?? []) as ExpenseItem[]);
|
||||||
const normalized = rows.map((it) => ({
|
|
||||||
...it,
|
|
||||||
entity: it.entity
|
|
||||||
? { ...it.entity, logo: resolveLogoUrl(it.entity.logo, API_BASE) }
|
|
||||||
: it.entity,
|
|
||||||
}));
|
|
||||||
setItems(normalized);
|
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
|
|||||||
@@ -29,13 +29,6 @@ export function isExpense(item: ExpenseItem): boolean {
|
|||||||
return (item.amount ?? 0) < 0;
|
return (item.amount ?? 0) < 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveLogoUrl(logo?: string, base?: string): string | undefined {
|
|
||||||
if (!logo) return undefined;
|
|
||||||
if (logo.startsWith("http") || logo.startsWith("data:")) return logo;
|
|
||||||
if (logo.startsWith("/") && base) return `${base.replace(/\/+$/, "")}${logo}`;
|
|
||||||
return logo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseOccurredAt(value?: string): Date {
|
export function parseOccurredAt(value?: string): Date {
|
||||||
const m = value?.match(/^(\d{2})-(\d{2})-(\d{4})$/);
|
const m = value?.match(/^(\d{2})-(\d{2})-(\d{4})$/);
|
||||||
if (!m) {
|
if (!m) {
|
||||||
|
|||||||
Reference in New Issue
Block a user