From 5fe9bdf11b4c04f61d5554d8beda44cc72776025 Mon Sep 17 00:00:00 2001 From: Ike Saunders Date: Fri, 29 Nov 2024 16:23:04 -0500 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20PR=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../1732833869225-CloudflareImagesAuthors.ts | 9 ++ .../cloudflareImagesSync/invalidImages.json | 114 ++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 db/migration/1732833869225-CloudflareImagesAuthors.ts create mode 100644 devTools/cloudflareImagesSync/invalidImages.json diff --git a/db/migration/1732833869225-CloudflareImagesAuthors.ts b/db/migration/1732833869225-CloudflareImagesAuthors.ts new file mode 100644 index 00000000000..821a6bc5a12 --- /dev/null +++ b/db/migration/1732833869225-CloudflareImagesAuthors.ts @@ -0,0 +1,9 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class CloudflareImagesAuthors1732833869225 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise {} + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/devTools/cloudflareImagesSync/invalidImages.json b/devTools/cloudflareImagesSync/invalidImages.json new file mode 100644 index 00000000000..700dfd54d48 --- /dev/null +++ b/devTools/cloudflareImagesSync/invalidImages.json @@ -0,0 +1,114 @@ +[ + { + "filename": "2019-Revision-–-World-Population-Growth-1700-2100.png", + "reason": "InvalidDimensions" + }, + { + "filename": "Annual-World-Population-since-10-thousand-BCE-1.png", + "reason": "TooManyMegapixels" + }, + { + "filename": "By-continent-and-decade-01.png", + "reason": "InvalidDimensions" + }, + { + "filename": "cable-tv-access-and-preference-for-a-son.png", + "reason": "InvalidDimensions" + }, + { + "filename": "calculating-risk-ratios-differences-odds.png", + "reason": "InvalidDimensions" + }, + { + "filename": "cancer-death-rate-decline-who-mdb-desktop.png", + "reason": "InvalidFormat" + }, + { + "filename": "cancer-death-rate-decline-who-mdb-mobile.png", + "reason": "InvalidFormat" + }, + { + "filename": "cardiovascular-diseases-types.png", + "reason": "InvalidDimensions" + }, + { + "filename": "causes-of-death-2019-full.png", + "reason": "InvalidDimensions" + }, + { + "filename": "Decade-in-which-smallpox-ceased-to-be-endemic-map.png", + "reason": "InvalidDimensions" + }, + { + "filename": "Energy-Units-01.png", + "reason": "InvalidDimensions" + }, + { + "filename": "England-death-rates.png", + "reason": "InvalidDimensions" + }, + { + "filename": "Famine-victims-since-1860s_March18.png", + "reason": "InvalidDimensions" + }, + { + "filename": "FEATURED-IMAGE-World-Population-Growth.png", + "reason": "InvalidDimensions" + }, + { + "filename": "Female-to-male-wage-ratio-01.png", + "reason": "InvalidDimensions" + }, + { + "filename": "future-yields-climate-distribution.png", + "reason": "InvalidDimensions" + }, + { + "filename": "Gender-Pay-Gap-01.png", + "reason": "InvalidDimensions" + }, + { + "filename": "Global-land-use-breakdown.png", + "reason": "InvalidDimensions" + }, + { + "filename": "Global-land-use-graphic.png", + "reason": "InvalidDimensions" + }, + { + "filename": "height-distribution.png", + "reason": "InvalidDimensions" + }, + { + "filename": "Multiple-risk-factors-full-1.png", + "reason": "TooManyMegapixels" + }, + { + "filename": "Norway-death-rates.png", + "reason": "InvalidDimensions" + }, + { + "filename": "not-reading-with-comprehension.png", + "reason": "InvalidDimensions" + }, + { + "filename": "Pandemics-Timeline-Death-Tolls-OWID.png", + "reason": "InvalidDimensions" + }, + { + "filename": "period-vs-cohort-explanation.png", + "reason": "TooManyMegapixels" + }, + { + "filename": "proportion-dying-latex.svg", + "reason": "InvalidFormat" + }, + { + "filename": "record-female-life-expectancy-since-1840-updated-2023.png", + "reason": "InvalidDimensions" + }, + { + "filename": "the-history-of-global-inequality-featured-image.png", + "reason": "InvalidDimensions" + } +] From 8bdbacd21853e0e84cd9b7bcc6a7677291bdf3d5 Mon Sep 17 00:00:00 2001 From: Ike Saunders Date: Sat, 30 Nov 2024 16:24:31 -0500 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=8E=89=20add=20users=5Fx=5Fimages=20t?= =?UTF-8?q?able?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../1732994843041-CloudflareImagesAuthors.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 db/migration/1732994843041-CloudflareImagesAuthors.ts diff --git a/db/migration/1732994843041-CloudflareImagesAuthors.ts b/db/migration/1732994843041-CloudflareImagesAuthors.ts new file mode 100644 index 00000000000..b20f7366d52 --- /dev/null +++ b/db/migration/1732994843041-CloudflareImagesAuthors.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class CloudflareImagesAuthors1732994843041 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`-- sql + CREATE TABLE users_x_images ( + userId INTEGER REFERENCES users(id), + imageId INTEGER REFERENCES images(id), + createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (userId, imageId) + ); + `) + } + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`-- sql + DROP TABLE users_x_images; + `) + } +} From d5a14e1d5181910b58f1f4c2dfaba319c3f3af8b Mon Sep 17 00:00:00 2001 From: Ike Saunders Date: Sun, 1 Dec 2024 00:37:36 -0500 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=8E=89=20add=20users=20column=20to=20?= =?UTF-8?q?images=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminSiteClient/ImagesIndexPage.tsx | 226 ++++++++++++++++-- adminSiteClient/admin.scss | 13 + adminSiteServer/apiRouter.ts | 41 +++- db/db.ts | 33 ++- .../cloudflareImagesSync/invalidImages.json | 114 --------- .../types/src/dbTypes/Images.ts | 7 + .../types/src/dbTypes/UsersXImages.ts | 9 + packages/@ourworldindata/types/src/index.ts | 8 + 8 files changed, 305 insertions(+), 146 deletions(-) delete mode 100644 devTools/cloudflareImagesSync/invalidImages.json create mode 100644 packages/@ourworldindata/types/src/dbTypes/UsersXImages.ts diff --git a/adminSiteClient/ImagesIndexPage.tsx b/adminSiteClient/ImagesIndexPage.tsx index 65e9114778b..5321bfd188b 100644 --- a/adminSiteClient/ImagesIndexPage.tsx +++ b/adminSiteClient/ImagesIndexPage.tsx @@ -5,27 +5,54 @@ 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" + +// Define map types +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 + postUserXImage: ( + user: DbPlainUser, + image: DbEnrichedImageWithUserId + ) => void + deleteUserXImage: ( + user: DbPlainUser, + image: DbEnrichedImageWithUserId + ) => void +} + +function mapToArray(map: { [id: string]: T }): T[] { + return Object.values(map) } function AltTextEditor({ @@ -33,7 +60,7 @@ function AltTextEditor({ text, patchImage, }: { - image: DbEnrichedImage + image: DbEnrichedImageWithUserId text: string patchImage: ImageEditorApi["patchImage"] }) { @@ -59,11 +86,85 @@ 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(() => + mapToArray(usersMap).map((user) => ({ + value: user.fullName, + label: user.fullName, + })) + ) + + const handleChange = (value: string) => { + setValue(value) + const lowercaseValue = value.toLowerCase() + setFilteredOptions( + mapToArray(usersMap) + .filter((user) => + user.fullName.toLowerCase().includes(lowercaseValue) + ) + .map((user) => ({ + value: user.fullName, + label: user.fullName, + })) + ) + } + + const handleSelect = async (option: { value?: string; label?: string }) => { + const selectedUser = mapToArray(usersMap).find( + (u) => u.fullName === option.value + ) + if (selectedUser) { + setValue(selectedUser.fullName) + await onUserSelect(selectedUser) + } + } + + if (isSetting) { + return ( + + ) + } + return ( +
+ + +
+ ) +} + function createColumns({ api, + users, }: { api: ImageEditorApi -}): ColumnsType { + users: UserMap +}): ColumnsType { return [ { title: "Preview", @@ -72,7 +173,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, + render: (_, image) => { + const user = users[image.userId] + if (!user) + return ( + + api.postUserXImage(user, image) + } + /> + ) + return ( +
+ {user.fullName} + +
+ ) + }, + }, { title: "Action", key: "action", @@ -166,10 +297,10 @@ function createColumns({ } function ImageUploadButton({ - setImages, + setImagesMap, admin, }: { - setImages: React.Dispatch> + setImagesMap: React.Dispatch> admin: Admin }) { function uploadImage({ file }: { file: string | Blob | RcFile }) { @@ -187,10 +318,13 @@ function ImageUploadButton({ const { image } = await admin.requestJSON<{ sucess: true - image: DbEnrichedImage + image: DbEnrichedImageWithUserId }>("/api/image", payload, "POST") - setImages((images) => [image, ...images]) + setImagesMap((imagesMap) => ({ + ...imagesMap, + [image.id]: image, + })) } reader.readAsDataURL(file) } @@ -205,45 +339,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, + })) + }, + postUserXImage: 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 }, + })) + } + }, + deleteUserXImage: 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) => + mapToArray(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 ( @@ -251,12 +427,12 @@ export function ImageIndexPage() {
setFilenameSearchValue(e.target.value)} style={{ width: 500, marginBottom: 20 }} /> - + 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..399c4623c2d 100644 --- a/adminSiteServer/apiRouter.ts +++ b/adminSiteServer/apiRouter.ts @@ -113,6 +113,7 @@ import { ChartViewsTableName, DbInsertChartView, DbEnrichedImage, + DbRawUsersXImages, } from "@ourworldindata/types" import { uuidv7 } from "uuidv7" import { @@ -1221,6 +1222,28 @@ 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("users_x_images").insert({ userId, imageId }) + 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("users_x_images").where({ userId, imageId }).delete() + return { success: true } + } +) + getRouteWithROTransaction( apiRouter, "/variables.json", @@ -3067,7 +3090,7 @@ getRouteNonIdempotentWithRWTransaction( "/images.json", async (_, res, trx) => { try { - const images = await db.getCloudflareImages(trx) + const images = await db.getCloudflareImagesWithUsers(trx) res.set("Cache-Control", "no-store") res.send({ images }) } catch (error) { @@ -3151,7 +3174,7 @@ postRouteWithRWTransaction(apiRouter, "/image", async (req, res, trx) => { } } - await trx("images").insert({ + const [imageId] = await trx("images").insert({ filename, originalWidth: dimensions.width, originalHeight: dimensions.height, @@ -3161,9 +3184,12 @@ postRouteWithRWTransaction(apiRouter, "/image", async (req, res, trx) => { updatedAt: new Date().getTime(), }) - const image = await trx("images") - .where("cloudflareId", "=", cloudflareId) - .first() + await trx("users_x_images").insert({ + userId: res.locals.user.id, + imageId, + }) + + const image = await db.getCloudflareImageWithUser(trx, filename) return { success: true, @@ -3217,7 +3243,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: { @@ -3234,6 +3260,9 @@ deleteRouteWithRWTransaction( throw new JsonError(JSON.stringify(response.errors)) } + await trx("users_x_images") + .where("imageId", "=", id) + .delete() await trx("images").where({ id }).delete() return { diff --git a/db/db.ts b/db/db.ts index 1b67f811bfe..d929217928a 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" @@ -183,7 +184,7 @@ export const getSlugsWithPublishedGdocsSuccessors = async ( return knexRaw( knex, `-- sql - SELECT + select p.slug FROM posts p @@ -726,3 +727,33 @@ export function getCloudflareImages( WHERE cloudflareId IS NOT NULL` ) } + +export function getCloudflareImagesWithUsers( + trx: KnexReadonlyTransaction +): Promise { + return knexRaw( + trx, + `-- sql + SELECT i.*, u.id AS userId + FROM images i + LEFT JOIN users_x_images uxi ON i.id = uxi.imageId + LEFT JOIN users u ON uxi.userId = u.id + WHERE i.cloudflareId IS NOT NULL` + ) +} + +export function getCloudflareImageWithUser( + trx: KnexReadonlyTransaction, + filename: string +): Promise { + return knexRawFirst( + trx, + `-- sql + SELECT i.*, u.id AS userId + FROM images i + LEFT JOIN users_x_images uxi ON i.id = uxi.imageId + LEFT JOIN users u ON uxi.userId = u.id + WHERE i.filename = ?`, + [filename] + ) +} diff --git a/devTools/cloudflareImagesSync/invalidImages.json b/devTools/cloudflareImagesSync/invalidImages.json deleted file mode 100644 index 700dfd54d48..00000000000 --- a/devTools/cloudflareImagesSync/invalidImages.json +++ /dev/null @@ -1,114 +0,0 @@ -[ - { - "filename": "2019-Revision-–-World-Population-Growth-1700-2100.png", - "reason": "InvalidDimensions" - }, - { - "filename": "Annual-World-Population-since-10-thousand-BCE-1.png", - "reason": "TooManyMegapixels" - }, - { - "filename": "By-continent-and-decade-01.png", - "reason": "InvalidDimensions" - }, - { - "filename": "cable-tv-access-and-preference-for-a-son.png", - "reason": "InvalidDimensions" - }, - { - "filename": "calculating-risk-ratios-differences-odds.png", - "reason": "InvalidDimensions" - }, - { - "filename": "cancer-death-rate-decline-who-mdb-desktop.png", - "reason": "InvalidFormat" - }, - { - "filename": "cancer-death-rate-decline-who-mdb-mobile.png", - "reason": "InvalidFormat" - }, - { - "filename": "cardiovascular-diseases-types.png", - "reason": "InvalidDimensions" - }, - { - "filename": "causes-of-death-2019-full.png", - "reason": "InvalidDimensions" - }, - { - "filename": "Decade-in-which-smallpox-ceased-to-be-endemic-map.png", - "reason": "InvalidDimensions" - }, - { - "filename": "Energy-Units-01.png", - "reason": "InvalidDimensions" - }, - { - "filename": "England-death-rates.png", - "reason": "InvalidDimensions" - }, - { - "filename": "Famine-victims-since-1860s_March18.png", - "reason": "InvalidDimensions" - }, - { - "filename": "FEATURED-IMAGE-World-Population-Growth.png", - "reason": "InvalidDimensions" - }, - { - "filename": "Female-to-male-wage-ratio-01.png", - "reason": "InvalidDimensions" - }, - { - "filename": "future-yields-climate-distribution.png", - "reason": "InvalidDimensions" - }, - { - "filename": "Gender-Pay-Gap-01.png", - "reason": "InvalidDimensions" - }, - { - "filename": "Global-land-use-breakdown.png", - "reason": "InvalidDimensions" - }, - { - "filename": "Global-land-use-graphic.png", - "reason": "InvalidDimensions" - }, - { - "filename": "height-distribution.png", - "reason": "InvalidDimensions" - }, - { - "filename": "Multiple-risk-factors-full-1.png", - "reason": "TooManyMegapixels" - }, - { - "filename": "Norway-death-rates.png", - "reason": "InvalidDimensions" - }, - { - "filename": "not-reading-with-comprehension.png", - "reason": "InvalidDimensions" - }, - { - "filename": "Pandemics-Timeline-Death-Tolls-OWID.png", - "reason": "InvalidDimensions" - }, - { - "filename": "period-vs-cohort-explanation.png", - "reason": "TooManyMegapixels" - }, - { - "filename": "proportion-dying-latex.svg", - "reason": "InvalidFormat" - }, - { - "filename": "record-female-life-expectancy-since-1840-updated-2023.png", - "reason": "InvalidDimensions" - }, - { - "filename": "the-history-of-global-inequality-featured-image.png", - "reason": "InvalidDimensions" - } -] diff --git a/packages/@ourworldindata/types/src/dbTypes/Images.ts b/packages/@ourworldindata/types/src/dbTypes/Images.ts index 702415b33d6..e7cae59f036 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 @@ -10,10 +12,15 @@ export interface DbInsertImage { cloudflareId?: string | 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/dbTypes/UsersXImages.ts b/packages/@ourworldindata/types/src/dbTypes/UsersXImages.ts new file mode 100644 index 00000000000..b008bea2534 --- /dev/null +++ b/packages/@ourworldindata/types/src/dbTypes/UsersXImages.ts @@ -0,0 +1,9 @@ +export const UsersXImagesTableName = "users_x_images" + +export interface DbInsertUsersXImages { + createdAt?: Date + imageId: number + userId: number +} + +export type DbRawUsersXImages = Required diff --git a/packages/@ourworldindata/types/src/index.ts b/packages/@ourworldindata/types/src/index.ts index c577fe4c52d..c3300acba4e 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,13 @@ export { type DbInsertUser, UsersTableName, } from "./dbTypes/Users.js" + +export { + UsersXImagesTableName, + type DbInsertUsersXImages, + type DbRawUsersXImages, +} from "./dbTypes/UsersXImages.js" + export { type DbRawVariable, type DbEnrichedVariable, From 8e035852307736d367282214fec74815aa972d98 Mon Sep 17 00:00:00 2001 From: Ike Saunders Date: Sun, 1 Dec 2024 15:05:26 -0500 Subject: [PATCH 4/7] =?UTF-8?q?=E2=9C=A8=20add=20author=20filtering=20in?= =?UTF-8?q?=20imagesindexpage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminSiteClient/ImagesIndexPage.tsx | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/adminSiteClient/ImagesIndexPage.tsx b/adminSiteClient/ImagesIndexPage.tsx index 5321bfd188b..df20dc15c84 100644 --- a/adminSiteClient/ImagesIndexPage.tsx +++ b/adminSiteClient/ImagesIndexPage.tsx @@ -135,8 +135,12 @@ function UserSelect({ return ( { + if (e.key === "Escape") setIsSetting(false) + }} onChange={handleChange} onSelect={handleSelect} options={filteredOptions} @@ -149,10 +153,10 @@ function UserSelect({ type="text" onClick={() => handleSelect({ value: admin.username })} > - Claim + + {admin.username} ) @@ -249,6 +253,19 @@ function createColumns({ title: "Owner", key: "userId", width: 200, + filters: [ + { + text: "Unassigned", + value: null as any, + }, + ...mapToArray(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) @@ -427,7 +444,7 @@ export function ImageIndexPage() {
setFilenameSearchValue(e.target.value)} style={{ width: 500, marginBottom: 20 }} From 32008ffea84556b3cf26837bc17f53a36e310d78 Mon Sep 17 00:00:00 2001 From: Ike Saunders Date: Sun, 1 Dec 2024 15:09:57 -0500 Subject: [PATCH 5/7] =?UTF-8?q?=E2=9C=A8=20image=20authors=20code=20cleanu?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminSiteClient/ImagesIndexPage.tsx | 9 ++++----- db/db.ts | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/adminSiteClient/ImagesIndexPage.tsx b/adminSiteClient/ImagesIndexPage.tsx index df20dc15c84..4b952b58751 100644 --- a/adminSiteClient/ImagesIndexPage.tsx +++ b/adminSiteClient/ImagesIndexPage.tsx @@ -28,7 +28,6 @@ import TextArea from "antd/es/input/TextArea.js" import { CLOUDFLARE_IMAGES_URL } from "../settings/clientSettings.js" import { keyBy } from "lodash" -// Define map types type ImageMap = Record type UserMap = Record @@ -314,10 +313,10 @@ function createColumns({ } function ImageUploadButton({ - setImagesMap, + setImages, admin, }: { - setImagesMap: React.Dispatch> + setImages: React.Dispatch> admin: Admin }) { function uploadImage({ file }: { file: string | Blob | RcFile }) { @@ -338,7 +337,7 @@ function ImageUploadButton({ image: DbEnrichedImageWithUserId }>("/api/image", payload, "POST") - setImagesMap((imagesMap) => ({ + setImages((imagesMap) => ({ ...imagesMap, [image.id]: image, })) @@ -449,7 +448,7 @@ export function ImageIndexPage() { onChange={(e) => setFilenameSearchValue(e.target.value)} style={{ width: 500, marginBottom: 20 }} /> - +
diff --git a/db/db.ts b/db/db.ts index d929217928a..00709b9f655 100644 --- a/db/db.ts +++ b/db/db.ts @@ -184,7 +184,7 @@ export const getSlugsWithPublishedGdocsSuccessors = async ( return knexRaw( knex, `-- sql - select + SELECT p.slug FROM posts p From 4e2efc41b4c6b5f7668cec59080db572d752f563 Mon Sep 17 00:00:00 2001 From: Ike Saunders Date: Sun, 1 Dec 2024 17:16:50 -0500 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=94=A8=20remove=20unused=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/migration/1732833869225-CloudflareImagesAuthors.ts | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 db/migration/1732833869225-CloudflareImagesAuthors.ts diff --git a/db/migration/1732833869225-CloudflareImagesAuthors.ts b/db/migration/1732833869225-CloudflareImagesAuthors.ts deleted file mode 100644 index 821a6bc5a12..00000000000 --- a/db/migration/1732833869225-CloudflareImagesAuthors.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm" - -export class CloudflareImagesAuthors1732833869225 - implements MigrationInterface -{ - public async up(queryRunner: QueryRunner): Promise {} - - public async down(queryRunner: QueryRunner): Promise {} -} From f4281a7ac1f2107a7d2df9cccdcd2f7d7d7ee718 Mon Sep 17 00:00:00 2001 From: Ike Saunders Date: Mon, 2 Dec 2024 18:30:50 -0500 Subject: [PATCH 7/7] =?UTF-8?q?=E2=9C=A8=20CF=20images=20authors=20PR=20fe?= =?UTF-8?q?edback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminSiteClient/ImagesIndexPage.tsx | 33 +++++++------------ adminSiteServer/apiRouter.ts | 20 ++++------- db/db.ts | 24 +++----------- ...1732994843041-CloudflareImagesAddUserId.ts | 20 +++++++++++ .../1732994843041-CloudflareImagesAuthors.ts | 21 ------------ .../types/src/dbTypes/Images.ts | 1 + .../types/src/dbTypes/UsersXImages.ts | 9 ----- packages/@ourworldindata/types/src/index.ts | 6 ---- 8 files changed, 44 insertions(+), 90 deletions(-) create mode 100644 db/migration/1732994843041-CloudflareImagesAddUserId.ts delete mode 100644 db/migration/1732994843041-CloudflareImagesAuthors.ts delete mode 100644 packages/@ourworldindata/types/src/dbTypes/UsersXImages.ts diff --git a/adminSiteClient/ImagesIndexPage.tsx b/adminSiteClient/ImagesIndexPage.tsx index 4b952b58751..31632fabbf5 100644 --- a/adminSiteClient/ImagesIndexPage.tsx +++ b/adminSiteClient/ImagesIndexPage.tsx @@ -40,20 +40,13 @@ type ImageEditorApi = { deleteImage: (image: DbEnrichedImageWithUserId) => void getImages: () => void getUsers: () => void - postUserXImage: ( - user: DbPlainUser, - image: DbEnrichedImageWithUserId - ) => void - deleteUserXImage: ( + postUserImage: (user: DbPlainUser, image: DbEnrichedImageWithUserId) => void + deleteUserImage: ( user: DbPlainUser, image: DbEnrichedImageWithUserId ) => void } -function mapToArray(map: { [id: string]: T }): T[] { - return Object.values(map) -} - function AltTextEditor({ image, text, @@ -99,7 +92,7 @@ function UserSelect({ const { admin } = useContext(AdminAppContext) const [value, setValue] = useState(initialValue) const [filteredOptions, setFilteredOptions] = useState(() => - mapToArray(usersMap).map((user) => ({ + Object.values(usersMap).map((user) => ({ value: user.fullName, label: user.fullName, })) @@ -109,21 +102,19 @@ function UserSelect({ setValue(value) const lowercaseValue = value.toLowerCase() setFilteredOptions( - mapToArray(usersMap) + Object.values(usersMap) .filter((user) => user.fullName.toLowerCase().includes(lowercaseValue) ) .map((user) => ({ - value: user.fullName, + value: String(user.id), label: user.fullName, })) ) } const handleSelect = async (option: { value?: string; label?: string }) => { - const selectedUser = mapToArray(usersMap).find( - (u) => u.fullName === option.value - ) + const selectedUser = usersMap[option.value!] if (selectedUser) { setValue(selectedUser.fullName) await onUserSelect(selectedUser) @@ -257,7 +248,7 @@ function createColumns({ text: "Unassigned", value: null as any, }, - ...mapToArray(users) + ...Object.values(users) .map((user) => ({ text: user.fullName, value: user.id, @@ -272,7 +263,7 @@ function createColumns({ - api.postUserXImage(user, image) + api.postUserImage(user, image) } /> ) @@ -281,7 +272,7 @@ function createColumns({ {user.fullName} @@ -391,7 +382,7 @@ export function ImageIndexPage() { [image.id]: response.image, })) }, - postUserXImage: async (user, image) => { + postUserImage: async (user, image) => { const result = await admin.requestJSON( `/api/users/${user.id}/image/${image.id}`, {}, @@ -404,7 +395,7 @@ export function ImageIndexPage() { })) } }, - deleteUserXImage: async (user, image) => { + deleteUserImage: async (user, image) => { const result = await admin.requestJSON( `/api/users/${user.id}/image/${image.id}`, {}, @@ -423,7 +414,7 @@ export function ImageIndexPage() { const filteredImages = useMemo( () => - mapToArray(images).filter((image) => + Object.values(images).filter((image) => image.filename .toLowerCase() .includes(filenameSearchValue.toLowerCase()) diff --git a/adminSiteServer/apiRouter.ts b/adminSiteServer/apiRouter.ts index 399c4623c2d..48484dbfbe4 100644 --- a/adminSiteServer/apiRouter.ts +++ b/adminSiteServer/apiRouter.ts @@ -113,7 +113,6 @@ import { ChartViewsTableName, DbInsertChartView, DbEnrichedImage, - DbRawUsersXImages, } from "@ourworldindata/types" import { uuidv7 } from "uuidv7" import { @@ -1228,7 +1227,7 @@ postRouteWithRWTransaction( async (req, res, trx) => { const userId = expectInt(req.params.userId) const imageId = expectInt(req.params.imageId) - await trx("users_x_images").insert({ userId, imageId }) + await trx("images").where({ id: imageId }).update({ userId }) return { success: true } } ) @@ -1239,7 +1238,9 @@ deleteRouteWithRWTransaction( async (req, res, trx) => { const userId = expectInt(req.params.userId) const imageId = expectInt(req.params.imageId) - await trx("users_x_images").where({ userId, imageId }).delete() + await trx("images") + .where({ id: imageId, userId }) + .update({ userId: null }) return { success: true } } ) @@ -3090,7 +3091,7 @@ getRouteNonIdempotentWithRWTransaction( "/images.json", async (_, res, trx) => { try { - const images = await db.getCloudflareImagesWithUsers(trx) + const images = await db.getCloudflareImages(trx) res.set("Cache-Control", "no-store") res.send({ images }) } catch (error) { @@ -3174,7 +3175,7 @@ postRouteWithRWTransaction(apiRouter, "/image", async (req, res, trx) => { } } - const [imageId] = await trx("images").insert({ + await trx("images").insert({ filename, originalWidth: dimensions.width, originalHeight: dimensions.height, @@ -3182,14 +3183,10 @@ postRouteWithRWTransaction(apiRouter, "/image", async (req, res, trx) => { // TODO: make defaultAlt nullable defaultAlt: "Default alt text", updatedAt: new Date().getTime(), - }) - - await trx("users_x_images").insert({ userId: res.locals.user.id, - imageId, }) - const image = await db.getCloudflareImageWithUser(trx, filename) + const image = await db.getCloudflareImage(trx, filename) return { success: true, @@ -3260,9 +3257,6 @@ deleteRouteWithRWTransaction( throw new JsonError(JSON.stringify(response.errors)) } - await trx("users_x_images") - .where("imageId", "=", id) - .delete() await trx("images").where({ id }).delete() return { diff --git a/db/db.ts b/db/db.ts index 00709b9f655..bab8530fc3e 100644 --- a/db/db.ts +++ b/db/db.ts @@ -722,37 +722,21 @@ 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 getCloudflareImagesWithUsers( - trx: KnexReadonlyTransaction -): Promise { - return knexRaw( - trx, - `-- sql - SELECT i.*, u.id AS userId - FROM images i - LEFT JOIN users_x_images uxi ON i.id = uxi.imageId - LEFT JOIN users u ON uxi.userId = u.id - WHERE i.cloudflareId IS NOT NULL` - ) -} - -export function getCloudflareImageWithUser( +export function getCloudflareImage( trx: KnexReadonlyTransaction, filename: string ): Promise { return knexRawFirst( trx, `-- sql - SELECT i.*, u.id AS userId - FROM images i - LEFT JOIN users_x_images uxi ON i.id = uxi.imageId - LEFT JOIN users u ON uxi.userId = u.id + 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/db/migration/1732994843041-CloudflareImagesAuthors.ts b/db/migration/1732994843041-CloudflareImagesAuthors.ts deleted file mode 100644 index b20f7366d52..00000000000 --- a/db/migration/1732994843041-CloudflareImagesAuthors.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm" - -export class CloudflareImagesAuthors1732994843041 - implements MigrationInterface -{ - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`-- sql - CREATE TABLE users_x_images ( - userId INTEGER REFERENCES users(id), - imageId INTEGER REFERENCES images(id), - createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE (userId, imageId) - ); - `) - } - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`-- sql - DROP TABLE users_x_images; - `) - } -} diff --git a/packages/@ourworldindata/types/src/dbTypes/Images.ts b/packages/@ourworldindata/types/src/dbTypes/Images.ts index e7cae59f036..e60157f9983 100644 --- a/packages/@ourworldindata/types/src/dbTypes/Images.ts +++ b/packages/@ourworldindata/types/src/dbTypes/Images.ts @@ -10,6 +10,7 @@ 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 diff --git a/packages/@ourworldindata/types/src/dbTypes/UsersXImages.ts b/packages/@ourworldindata/types/src/dbTypes/UsersXImages.ts deleted file mode 100644 index b008bea2534..00000000000 --- a/packages/@ourworldindata/types/src/dbTypes/UsersXImages.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const UsersXImagesTableName = "users_x_images" - -export interface DbInsertUsersXImages { - createdAt?: Date - imageId: number - userId: number -} - -export type DbRawUsersXImages = Required diff --git a/packages/@ourworldindata/types/src/index.ts b/packages/@ourworldindata/types/src/index.ts index c3300acba4e..fa5760c6fbc 100644 --- a/packages/@ourworldindata/types/src/index.ts +++ b/packages/@ourworldindata/types/src/index.ts @@ -663,12 +663,6 @@ export { UsersTableName, } from "./dbTypes/Users.js" -export { - UsersXImagesTableName, - type DbInsertUsersXImages, - type DbRawUsersXImages, -} from "./dbTypes/UsersXImages.js" - export { type DbRawVariable, type DbEnrichedVariable,