diff --git a/adminSiteClient/ImagesIndexPage.tsx b/adminSiteClient/ImagesIndexPage.tsx index 65e9114778b..31632fabbf5 100644 --- a/adminSiteClient/ImagesIndexPage.tsx +++ b/adminSiteClient/ImagesIndexPage.tsx @@ -5,27 +5,46 @@ import React, { useMemo, useState, } from "react" -import { Button, Flex, Input, Popconfirm, Space, Table, Upload } from "antd" - +import { + Button, + Flex, + Input, + Mentions, + Popconfirm, + Space, + Table, + Upload, +} from "antd" import { AdminLayout } from "./AdminLayout.js" import { AdminAppContext } from "./AdminAppContext.js" -import { DbEnrichedImage } from "@ourworldindata/types" +import { DbEnrichedImageWithUserId, DbPlainUser } from "@ourworldindata/types" import { Timeago } from "./Forms.js" import { ColumnsType } from "antd/es/table/InternalTable.js" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" -import { faUpload } from "@fortawesome/free-solid-svg-icons" +import { faClose, faUpload } from "@fortawesome/free-solid-svg-icons" import { Admin } from "./Admin.js" import { RcFile } from "antd/es/upload/interface.js" import TextArea from "antd/es/input/TextArea.js" import { CLOUDFLARE_IMAGES_URL } from "../settings/clientSettings.js" +import { keyBy } from "lodash" + +type ImageMap = Record + +type UserMap = Record type ImageEditorApi = { patchImage: ( - image: DbEnrichedImage, - patch: Partial + image: DbEnrichedImageWithUserId, + patch: Partial ) => void - deleteImage: (image: DbEnrichedImage) => void + deleteImage: (image: DbEnrichedImageWithUserId) => void getImages: () => void + getUsers: () => void + postUserImage: (user: DbPlainUser, image: DbEnrichedImageWithUserId) => void + deleteUserImage: ( + user: DbPlainUser, + image: DbEnrichedImageWithUserId + ) => void } function AltTextEditor({ @@ -33,7 +52,7 @@ function AltTextEditor({ text, patchImage, }: { - image: DbEnrichedImage + image: DbEnrichedImageWithUserId text: string patchImage: ImageEditorApi["patchImage"] }) { @@ -59,11 +78,87 @@ function AltTextEditor({ ) } +function UserSelect({ + usersMap, + initialValue = "", + onUserSelect, +}: { + usersMap: UserMap + initialValue?: string + onUserSelect: (user: DbPlainUser) => void +}) { + const [isSetting, setIsSetting] = useState(false) + + const { admin } = useContext(AdminAppContext) + const [value, setValue] = useState(initialValue) + const [filteredOptions, setFilteredOptions] = useState(() => + Object.values(usersMap).map((user) => ({ + value: user.fullName, + label: user.fullName, + })) + ) + + const handleChange = (value: string) => { + setValue(value) + const lowercaseValue = value.toLowerCase() + setFilteredOptions( + Object.values(usersMap) + .filter((user) => + user.fullName.toLowerCase().includes(lowercaseValue) + ) + .map((user) => ({ + value: String(user.id), + label: user.fullName, + })) + ) + } + + const handleSelect = async (option: { value?: string; label?: string }) => { + const selectedUser = usersMap[option.value!] + if (selectedUser) { + setValue(selectedUser.fullName) + await onUserSelect(selectedUser) + } + } + + if (isSetting) { + return ( + { + if (e.key === "Escape") setIsSetting(false) + }} + onChange={handleChange} + onSelect={handleSelect} + options={filteredOptions} + /> + ) + } + return ( +
+ + +
+ ) +} + function createColumns({ api, + users, }: { api: ImageEditorApi -}): ColumnsType { + users: UserMap +}): ColumnsType { return [ { title: "Preview", @@ -72,7 +167,9 @@ function createColumns({ key: "cloudflareId", render: (cloudflareId, { originalWidth }) => { const srcFor = (w: number) => - `${CLOUDFLARE_IMAGES_URL}/${encodeURIComponent(cloudflareId)}/w=${w}` + `${CLOUDFLARE_IMAGES_URL}/${encodeURIComponent( + cloudflareId + )}/w=${w}` return (
, }, + { + title: "Owner", + key: "userId", + width: 200, + filters: [ + { + text: "Unassigned", + value: null as any, + }, + ...Object.values(users) + .map((user) => ({ + text: user.fullName, + value: user.id, + })) + .sort((a, b) => a.text.localeCompare(b.text)), + ], + onFilter: (value, record) => record.userId === value, + render: (_, image) => { + const user = users[image.userId] + if (!user) + return ( + + api.postUserImage(user, image) + } + /> + ) + return ( +
+ {user.fullName} + +
+ ) + }, + }, { title: "Action", key: "action", @@ -169,7 +307,7 @@ function ImageUploadButton({ setImages, admin, }: { - setImages: React.Dispatch> + setImages: React.Dispatch> admin: Admin }) { function uploadImage({ file }: { file: string | Blob | RcFile }) { @@ -187,10 +325,13 @@ function ImageUploadButton({ const { image } = await admin.requestJSON<{ sucess: true - image: DbEnrichedImage + image: DbEnrichedImageWithUserId }>("/api/image", payload, "POST") - setImages((images) => [image, ...images]) + setImages((imagesMap) => ({ + ...imagesMap, + [image.id]: image, + })) } reader.readAsDataURL(file) } @@ -205,45 +346,87 @@ function ImageUploadButton({ export function ImageIndexPage() { const { admin } = useContext(AdminAppContext) - const [images, setImages] = useState([]) + const [images, setImages] = useState({}) + const [users, setUsers] = useState({}) const [filenameSearchValue, setFilenameSearchValue] = useState("") + const api = useMemo( (): ImageEditorApi => ({ deleteImage: async (image) => { await admin.requestJSON(`/api/images/${image.id}`, {}, "DELETE") - setImages((images) => images.filter((i) => i.id !== image.id)) + setImages((prevMap) => { + const newMap = { ...prevMap } + delete newMap[image.id] + return newMap + }) }, getImages: async () => { const json = await admin.getJSON<{ - images: DbEnrichedImage[] + images: DbEnrichedImageWithUserId[] }>("/api/images.json") - setImages(json.images) + setImages(keyBy(json.images, "id")) + }, + getUsers: async () => { + const json = await admin.getJSON<{ users: DbPlainUser[] }>( + "/api/users.json" + ) + setUsers(keyBy(json.users, "id")) }, patchImage: async (image, patch) => { const response = await admin.requestJSON<{ success: true - image: DbEnrichedImage + image: DbEnrichedImageWithUserId }>(`/api/images/${image.id}`, patch, "PATCH") - setImages((images) => - images.map((i) => (i.id === image.id ? response.image : i)) + setImages((prevMap) => ({ + ...prevMap, + [image.id]: response.image, + })) + }, + postUserImage: async (user, image) => { + const result = await admin.requestJSON( + `/api/users/${user.id}/image/${image.id}`, + {}, + "POST" ) + if (result.success) { + setImages((prevMap) => ({ + ...prevMap, + [image.id]: { ...prevMap[image.id], userId: user.id }, + })) + } + }, + deleteUserImage: async (user, image) => { + const result = await admin.requestJSON( + `/api/users/${user.id}/image/${image.id}`, + {}, + "DELETE" + ) + if (result.success) { + setImages((prevMap) => ({ + ...prevMap, + [image.id]: { ...prevMap[image.id], userId: null }, + })) + } }, }), [admin] ) + const filteredImages = useMemo( () => - images.filter((image) => + Object.values(images).filter((image) => image.filename .toLowerCase() .includes(filenameSearchValue.toLowerCase()) ), [images, filenameSearchValue] ) - const columns = useMemo(() => createColumns({ api }), [api]) + + const columns = useMemo(() => createColumns({ api, users }), [api, users]) useEffect(() => { void api.getImages() + void api.getUsers() }, [api]) return ( diff --git a/adminSiteClient/admin.scss b/adminSiteClient/admin.scss index a48897a273d..b1d4b8865e6 100644 --- a/adminSiteClient/admin.scss +++ b/adminSiteClient/admin.scss @@ -1207,3 +1207,16 @@ main:not(.ChartEditorPage):not(.GdocsEditPage) { display: block; } } + +.ImageIndexPage { + .ImageIndexPage__delete-user-button { + border-radius: 50%; + margin-left: 8px; + height: 16px; + font-size: 0.8em; + align-items: center; + display: inline-flex; + width: 16px; + vertical-align: -2px; + } +} diff --git a/adminSiteServer/apiRouter.ts b/adminSiteServer/apiRouter.ts index 734d769b356..48484dbfbe4 100644 --- a/adminSiteServer/apiRouter.ts +++ b/adminSiteServer/apiRouter.ts @@ -1221,6 +1221,30 @@ postRouteWithRWTransaction( } ) +postRouteWithRWTransaction( + apiRouter, + "/users/:userId/image/:imageId", + async (req, res, trx) => { + const userId = expectInt(req.params.userId) + const imageId = expectInt(req.params.imageId) + await trx("images").where({ id: imageId }).update({ userId }) + return { success: true } + } +) + +deleteRouteWithRWTransaction( + apiRouter, + "/users/:userId/image/:imageId", + async (req, res, trx) => { + const userId = expectInt(req.params.userId) + const imageId = expectInt(req.params.imageId) + await trx("images") + .where({ id: imageId, userId }) + .update({ userId: null }) + return { success: true } + } +) + getRouteWithROTransaction( apiRouter, "/variables.json", @@ -3159,11 +3183,10 @@ postRouteWithRWTransaction(apiRouter, "/image", async (req, res, trx) => { // TODO: make defaultAlt nullable defaultAlt: "Default alt text", updatedAt: new Date().getTime(), + userId: res.locals.user.id, }) - const image = await trx("images") - .where("cloudflareId", "=", cloudflareId) - .first() + const image = await db.getCloudflareImage(trx, filename) return { success: true, @@ -3217,7 +3240,7 @@ deleteRouteWithRWTransaction( } const response = await fetch( - `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_IMAGES_ACCOUNT_ID}/images/v1/${image.cloudflareId}`, + `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_IMAGES_ACCOUNT_ID}/images/v1/${encodeURIComponent(image.cloudflareId!)}`, { method: "DELETE", headers: { diff --git a/db/db.ts b/db/db.ts index 1b67f811bfe..bab8530fc3e 100644 --- a/db/db.ts +++ b/db/db.ts @@ -29,6 +29,7 @@ import { TagGraphNode, MinimalExplorerInfo, DbEnrichedImage, + DbEnrichedImageWithUserId, } from "@ourworldindata/types" import { groupBy, uniq } from "lodash" import { gdocFromJSON } from "./model/Gdoc/GdocFactory.js" @@ -721,8 +722,22 @@ export function getCloudflareImages( return knexRaw( trx, `-- sql - SELECT filename, cloudflareId, id, defaultAlt, originalHeight, originalWidth, updatedAt + SELECT * FROM images WHERE cloudflareId IS NOT NULL` ) } + +export function getCloudflareImage( + trx: KnexReadonlyTransaction, + filename: string +): Promise { + return knexRawFirst( + trx, + `-- sql + SELECT * + FROM images + WHERE i.filename = ?`, + [filename] + ) +} diff --git a/db/migration/1732994843041-CloudflareImagesAddUserId.ts b/db/migration/1732994843041-CloudflareImagesAddUserId.ts new file mode 100644 index 00000000000..9305ec2c417 --- /dev/null +++ b/db/migration/1732994843041-CloudflareImagesAddUserId.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class CloudflareImagesAddUserId1732994843041 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`-- sql + ALTER TABLE images + ADD COLUMN userId INTEGER, + ADD CONSTRAINT fk_user_images + FOREIGN KEY (userId) REFERENCES users(id) + ON DELETE SET NULL; + `) + } + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`-- sql + ALTER TABLE images DROP COLUMN userId; + `) + } +} diff --git a/packages/@ourworldindata/types/src/dbTypes/Images.ts b/packages/@ourworldindata/types/src/dbTypes/Images.ts index 702415b33d6..e60157f9983 100644 --- a/packages/@ourworldindata/types/src/dbTypes/Images.ts +++ b/packages/@ourworldindata/types/src/dbTypes/Images.ts @@ -1,3 +1,5 @@ +import { DbPlainUser } from "./Users.js" + export const ImagesTableName = "images" export interface DbInsertImage { googleId: string @@ -8,12 +10,18 @@ export interface DbInsertImage { originalHeight?: number | null updatedAt?: string | null // MySQL Date objects round to the nearest second, whereas Google includes milliseconds so we store as an epoch of type bigint to avoid any conversion issues cloudflareId?: string | null + userId?: number | null } export type DbRawImage = Required + export type DbEnrichedImage = Omit & { updatedAt: number | null } +export type DbEnrichedImageWithUserId = DbEnrichedImage & { + userId: DbPlainUser["id"] +} + export function parseImageRow(row: DbRawImage): DbEnrichedImage { return { ...row, updatedAt: parseImageUpdatedAt(row.updatedAt) } } diff --git a/packages/@ourworldindata/types/src/index.ts b/packages/@ourworldindata/types/src/index.ts index c577fe4c52d..fa5760c6fbc 100644 --- a/packages/@ourworldindata/types/src/index.ts +++ b/packages/@ourworldindata/types/src/index.ts @@ -528,6 +528,7 @@ export { export { type DbRawImage, type DbEnrichedImage, + type DbEnrichedImageWithUserId, type DbInsertImage, parseImageRow, parseImageUpdatedAt, @@ -661,6 +662,7 @@ export { type DbInsertUser, UsersTableName, } from "./dbTypes/Users.js" + export { type DbRawVariable, type DbEnrichedVariable,