diff --git a/adminSiteClient/ImagesIndexPage.tsx b/adminSiteClient/ImagesIndexPage.tsx index fa51b5c9d53..5b62e77c172 100644 --- a/adminSiteClient/ImagesIndexPage.tsx +++ b/adminSiteClient/ImagesIndexPage.tsx @@ -5,7 +5,16 @@ import React, { useMemo, useState, } from "react" -import { Button, Flex, Input, Mentions, Popconfirm, Table, Upload } from "antd" +import { + Button, + Flex, + Input, + Mentions, + Popconfirm, + Popover, + Table, + Upload, +} from "antd" import { AdminLayout } from "./AdminLayout.js" import { AdminAppContext } from "./AdminAppContext.js" import { DbEnrichedImageWithUserId, DbPlainUser } from "@ourworldindata/types" @@ -20,14 +29,20 @@ import { } from "@fortawesome/free-solid-svg-icons" import { RcFile } from "antd/es/upload/interface.js" import { CLOUDFLARE_IMAGES_URL } from "../settings/clientSettings.js" -import { keyBy } from "lodash" +import { Dictionary, keyBy } from "lodash" import cx from "classnames" type ImageMap = Record type UserMap = Record +type UsageInfo = { + title: string + id: string +} + type ImageEditorApi = { + getUsage: () => void getAltText: (id: number) => Promise<{ altText: string; success: boolean }> patchImage: ( image: DbEnrichedImageWithUserId, @@ -183,12 +198,49 @@ function UserSelect({ ) } +function UsageViewer({ usage }: { usage: UsageInfo[] | undefined }) { + const content = ( +
+ {usage ? ( + + ) : null} +
+ ) + + return ( + + + + ) +} + function createColumns({ api, users, + usage, }: { api: ImageEditorApi users: UserMap + usage: Dictionary }): ColumnsType { return [ { @@ -319,22 +371,35 @@ function createColumns({ title: "Action", key: "action", width: 100, - render: (_, image) => ( - - - api.deleteImage(image)} - okText="Yes" - cancelText="No" - > - - - - ), + render: (_, image) => { + const isDeleteDisabled = !!(usage && usage[image.id]?.length) + return ( + + + + api.deleteImage(image)} + okText="Yes" + cancelText="No" + > + + + + ) + }, }, ] } @@ -421,10 +486,18 @@ export function ImageIndexPage() { const { admin } = useContext(AdminAppContext) const [images, setImages] = useState({}) const [users, setUsers] = useState({}) + const [usage, setUsage] = useState>({}) const [filenameSearchValue, setFilenameSearchValue] = useState("") const api = useMemo( (): ImageEditorApi => ({ + getUsage: async () => { + const usage = await admin.requestJSON<{ + success: true + usage: Dictionary + }>(`/api/images/usage`, {}, "GET") + setUsage(usage.usage) + }, getAltText: (id) => { return admin.requestJSON<{ success: true @@ -527,11 +600,15 @@ export function ImageIndexPage() { [images, filenameSearchValue] ) - const columns = useMemo(() => createColumns({ api, users }), [api, users]) + const columns = useMemo( + () => createColumns({ api, users, usage }), + [api, users, usage] + ) useEffect(() => { void api.getImages() void api.getUsers() + void api.getUsage() }, [api]) return ( diff --git a/adminSiteClient/admin.scss b/adminSiteClient/admin.scss index 745bf9add7e..cb3c4f11e0e 100644 --- a/adminSiteClient/admin.scss +++ b/adminSiteClient/admin.scss @@ -1236,6 +1236,7 @@ main:not(.ChartEditorPage):not(.GdocsEditPage) { } } } + .ImageIndexPage__unsaved-chip { color: gray; background-color: lightgray; @@ -1248,3 +1249,20 @@ main:not(.ChartEditorPage):not(.GdocsEditPage) { position: relative; } } + +.ImageIndexPage__usage-chip { + background-color: #ddd; + padding: 2px 4px; + display: inline-block; + border-radius: 50%; + font-size: 12px; + font-weight: bold; + line-height: 12px; +} + +.ImageIndexPage__usage-list { + max-width: 300px; + padding-left: 16px; + margin-top: -8px; + margin-bottom: 0; +} diff --git a/adminSiteServer/apiRouter.ts b/adminSiteServer/apiRouter.ts index 2cde361a95f..98f85676bd0 100644 --- a/adminSiteServer/apiRouter.ts +++ b/adminSiteServer/apiRouter.ts @@ -3315,6 +3315,15 @@ deleteRouteWithRWTransaction( } ) +getRouteWithROTransaction(apiRouter, "/images/usage", async (_, __, trx) => { + const usage = await db.getImageUsage(trx) + + return { + success: true, + usage, + } +}) + getRouteWithROTransaction( apiRouter, `/gpt/suggest-topics/${TaggableType.Charts}/:chartId.json`, diff --git a/db/db.ts b/db/db.ts index 9f4e4c69528..cea22490e61 100644 --- a/db/db.ts +++ b/db/db.ts @@ -741,3 +741,40 @@ export function getCloudflareImage( [filename] ) } + +/** + * Get the title, slug, and googleId of all gdocs that reference each image + */ +export function getImageUsage(trx: KnexReadonlyTransaction): Promise< + Record< + number, + { + title: string + id: string + }[] + > +> { + return knexRaw<{ + imageId: number + posts: string + }>( + trx, + `-- sql + SELECT + i.id as imageId, + JSON_ARRAYAGG( + JSON_OBJECT( + 'title', p.content->>'$.title', + 'id', p.id + ) + ) as posts + FROM posts_gdocs p + JOIN posts_gdocs_x_images pi ON p.id = pi.gdocId + JOIN images i ON pi.imageId = i.id + GROUP BY i.id` + ).then((results) => + Object.fromEntries( + results.map((result) => [result.imageId, JSON.parse(result.posts)]) + ) + ) +}