From 27cef96404a45f354819be10c02fc0b2b5d623d9 Mon Sep 17 00:00:00 2001 From: Luc Date: Wed, 4 Dec 2024 12:52:25 +0100 Subject: [PATCH] Introduce basic item logs --- .../migrations/0004_logentry_owners.down.sql | 3 + engine/migrations/0004_logentry_owners.up.sql | 3 + engine/src/models/item/mod.rs | 39 +- engine/src/models/log/mod.rs | 19 +- engine/src/modules/storage/mod.rs | 2 +- engine/src/routes/items/mod.rs | 51 ++- web/src/api/item.ts | 16 + web/src/api/schema.gen.ts | 361 ++++++++++++++---- .../components/item/logs/ItemLogSection.tsx | 65 ++++ web/src/index.css | 4 + web/src/routes/item/$itemId/index.tsx | 2 + 11 files changed, 468 insertions(+), 97 deletions(-) create mode 100644 engine/migrations/0004_logentry_owners.down.sql create mode 100644 engine/migrations/0004_logentry_owners.up.sql create mode 100644 web/src/components/item/logs/ItemLogSection.tsx diff --git a/engine/migrations/0004_logentry_owners.down.sql b/engine/migrations/0004_logentry_owners.down.sql new file mode 100644 index 0000000..471cf62 --- /dev/null +++ b/engine/migrations/0004_logentry_owners.down.sql @@ -0,0 +1,3 @@ +-- Alter logentry resource_id to be integer +ALTER TABLE logs +ALTER COLUMN resource_id TYPE INTEGER USING resource_id::INTEGER; diff --git a/engine/migrations/0004_logentry_owners.up.sql b/engine/migrations/0004_logentry_owners.up.sql new file mode 100644 index 0000000..335150b --- /dev/null +++ b/engine/migrations/0004_logentry_owners.up.sql @@ -0,0 +1,3 @@ +--- Alter logentry resource_id to be text +ALTER TABLE logs +ALTER COLUMN resource_id TYPE TEXT USING resource_id::TEXT; diff --git a/engine/src/models/item/mod.rs b/engine/src/models/item/mod.rs index 68459d1..0dfd733 100644 --- a/engine/src/models/item/mod.rs +++ b/engine/src/models/item/mod.rs @@ -4,7 +4,12 @@ use serde::{Deserialize, Serialize}; use sqlx::{query, query_as}; use tracing::info; -use crate::{database::Database, modules::search::Search, routes::items::{ItemUpdateMediaStatus, ItemUpdatePayload}}; +use super::log::LogEntry; +use crate::{ + database::Database, + modules::search::Search, + routes::items::{ItemUpdateMediaStatus, ItemUpdatePayload}, +}; pub mod field; pub mod media; @@ -57,9 +62,21 @@ impl Item { } pub async fn insert(&self, db: &Database) -> Result { - query_as!(Item, "INSERT INTO items (item_id, name, owner_id, location_id, product_id) VALUES ($1, $2, $3, $4, $5) RETURNING *", self.item_id, self.name, self.owner_id, self.location_id, self.product_id) + let item = query_as!(Item, "INSERT INTO items (item_id, name, owner_id, location_id, product_id) VALUES ($1, $2, $3, $4, $5) RETURNING *", self.item_id, self.name, self.owner_id, self.location_id, self.product_id) .fetch_one(&db.pool) - .await + .await?; + + LogEntry::new( + db, + "item", + &item.item_id, + item.owner_id.unwrap_or(1), + "create", + serde_json::to_string(&item).unwrap().as_str(), + ) + .await; + + Ok(item) } pub async fn get_all(db: &Database) -> Result, sqlx::Error> { @@ -118,10 +135,10 @@ impl Item { } } - pub async fn remove_search(&self, search: &Option, db: &Database) -> Result { + pub async fn remove_search(&self, search: &Option, _db: &Database) -> Result { match search { Some(search) => { - search + let _ = search .client .index("items") .delete_document(&self.item_id) @@ -146,12 +163,12 @@ impl Item { pub async fn edit_by_id( search: &Option, db: &Database, - data: ItemUpdatePayload, + data: &ItemUpdatePayload, item_id: &str, ) -> Result { let mut tx = db.pool.begin().await?; - if let Some(name) = data.name { + if let Some(name) = &data.name { query!( "UPDATE items SET name = $1 WHERE item_id = $2", name, @@ -191,17 +208,19 @@ impl Item { .await?; } - if let Some(media) = data.media { + if let Some(media) = &data.media { for media in media { match media.status { ItemUpdateMediaStatus::ExistingMedia => { // nothing needed here - }, + } ItemUpdateMediaStatus::NewMedia => { ItemMedia::new(db, item_id, media.media_id).await.unwrap(); } ItemUpdateMediaStatus::RemovedMedia => { - ItemMedia::delete(db, item_id, media.media_id).await.unwrap(); + ItemMedia::delete(db, item_id, media.media_id) + .await + .unwrap(); } } } diff --git a/engine/src/models/log/mod.rs b/engine/src/models/log/mod.rs index 339e51e..53125ce 100644 --- a/engine/src/models/log/mod.rs +++ b/engine/src/models/log/mod.rs @@ -12,7 +12,7 @@ use crate::database::Database; pub struct LogEntry { pub log_id: i32, pub resource_type: String, - pub resource_id: i32, + pub resource_id: String, pub user_id: i32, pub action: String, pub data: String, @@ -24,11 +24,11 @@ impl LogEntry { /// Let postgres generate the id and created_at pub async fn new( db: &Database, - resource_type: String, - resource_id: i32, + resource_type: &str, + resource_id: &str, user_id: i32, - action: String, - data: String, + action: &str, + data: &str, ) -> Result { let log_entry = query_as!( LogEntry, @@ -47,4 +47,13 @@ impl LogEntry { Ok(log_entry) } + + /// Find by resource_type and resource_id + pub async fn find_by_resource(db: &Database, resource_type: &str, resource_id: &str) -> Result, sqlx::Error> { + let log_entries = query_as!(LogEntry, "SELECT * FROM logs WHERE resource_type = $1 AND resource_id = $2 ORDER BY created_at DESC", resource_type, resource_id) + .fetch_all(&db.pool) + .await?; + + Ok(log_entries) + } } diff --git a/engine/src/modules/storage/mod.rs b/engine/src/modules/storage/mod.rs index f5f282a..76f199e 100644 --- a/engine/src/modules/storage/mod.rs +++ b/engine/src/modules/storage/mod.rs @@ -2,7 +2,7 @@ use std::env; use aws_config::Region; use aws_sdk_s3::{ - config::Credentials, primitives::ByteStream, types::{BucketCannedAcl, CreateBucketConfiguration}, Client, + config::Credentials, primitives::ByteStream, types::CreateBucketConfiguration, Client, }; use tracing::info; use uuid::Uuid; diff --git a/engine/src/routes/items/mod.rs b/engine/src/routes/items/mod.rs index 0e1eb46..f7b70ba 100644 --- a/engine/src/routes/items/mod.rs +++ b/engine/src/routes/items/mod.rs @@ -9,12 +9,15 @@ use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use tracing::info; +use super::ApiTags; use crate::{ auth::middleware::AuthToken, - models::item::{media::ItemMedia, Item}, + models::{ + item::{media::ItemMedia, Item}, + log::LogEntry, + }, state::AppState, }; -use super::ApiTags; pub struct ItemsApi; @@ -60,7 +63,7 @@ pub struct ItemUpdateMediaPayload { #[OpenApi] impl ItemsApi { /// /item/owned - /// + /// /// Get all items owned by the current user #[oai(path = "/item/owned", method = "get", tag = "ApiTags::Items")] async fn get_owned_items( @@ -79,7 +82,7 @@ impl ItemsApi { } /// /item - /// + /// /// Create an Item #[oai(path = "/item", method = "post", tag = "ApiTags::Items")] async fn create_item( @@ -104,7 +107,7 @@ impl ItemsApi { } /// /item/next - /// + /// /// Suggest next Item Id #[oai(path = "/item/next", method = "get", tag = "ApiTags::Items")] async fn next_item_id(&self, state: Data<&Arc>) -> Json { @@ -116,7 +119,7 @@ impl ItemsApi { } /// /item/:item_id - /// + /// /// Delete an Item by `item_id` #[oai(path = "/item/:item_id", method = "delete", tag = "ApiTags::Items")] async fn delete_item( @@ -136,7 +139,7 @@ impl ItemsApi { } /// /item/:item_id - /// + /// /// Get an Item by `item_id` #[oai(path = "/item/:item_id", method = "get", tag = "ApiTags::Items")] async fn get_item( @@ -154,7 +157,7 @@ impl ItemsApi { } /// /item/:item_id - /// + /// /// Edit an Item by `item_id` /// This updates the `name`, `owner_id`, `location_id`, `product_id`, and `media` (linking `"new-media"`, and removing `"removed-media"`) #[oai(path = "/item/:item_id", method = "patch", tag = "ApiTags::Items")] @@ -165,12 +168,23 @@ impl ItemsApi { item_id: Path, data: Json, ) -> Result<()> { - Item::edit_by_id(&state.search, &state.database, data.0, &item_id.0).await; + let _ = Item::edit_by_id(&state.search, &state.database, &data.0, &item_id.0).await; + + let _ = LogEntry::new( + &state.database, + "item", + &item_id.0, + auth.ok().unwrap().session.user_id, + "edit", + &serde_json::to_string(&data.0).unwrap(), + ) + .await; + Ok(()) } /// /item/:item_id/media - /// + /// /// Get all media for an Item by `item_id` #[oai(path = "/item/:item_id/media", method = "get", tag = "ApiTags::Items")] async fn get_item_media( @@ -185,4 +199,21 @@ impl ItemsApi { Ok(Json(media.iter().map(|m| m.media_id).collect())) } + + /// /item/:item_id/logs + /// + /// Get all logs for an Item by `item_id` + #[oai(path = "/item/:item_id/logs", method = "get", tag = "ApiTags::Items")] + async fn get_item_logs( + &self, + state: Data<&Arc>, + auth: AuthToken, + item_id: Path, + ) -> Result>> { + Ok(Json( + LogEntry::find_by_resource(&state.database, "item", &item_id.0) + .await + .unwrap(), + )) + } } diff --git a/web/src/api/item.ts b/web/src/api/item.ts index 283a529..d0edca9 100644 --- a/web/src/api/item.ts +++ b/web/src/api/item.ts @@ -53,6 +53,22 @@ export const getApiItemMedia = ( }), }); +export type ApiLogResponse = + paths['/item/{item_id}/logs']['get']['responses']['200']['content']['application/json; charset=utf-8']; + +export const getApiItemLogs = ( + item_id: string +): UseQueryOptions => ({ + queryKey: ['item', item_id, 'logs'], + queryFn: getHttp('/api/item/' + item_id + '/logs', { + auth: 'include', + }), +}); + +export const useApiItemLogs = (item_id: string) => { + return useQuery(getApiItemLogs(item_id)); +}; + export const useApiItemMedia = (item_id: string) => { return useQuery(getApiItemMedia(item_id)); }; diff --git a/web/src/api/schema.gen.ts b/web/src/api/schema.gen.ts index 8888bad..cc26728 100644 --- a/web/src/api/schema.gen.ts +++ b/web/src/api/schema.gen.ts @@ -4,13 +4,17 @@ */ export type paths = { - "/me": { + "/item/owned": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + /** + * /item/owned + * @description Get all items owned by the current user + */ get: { parameters: { query?: never; @@ -25,7 +29,7 @@ export type paths = { [name: string]: unknown; }; content: { - "application/json; charset=utf-8": components["schemas"]["User"]; + "application/json; charset=utf-8": components["schemas"]["Item"][]; }; }; }; @@ -38,14 +42,20 @@ export type paths = { patch?: never; trace?: never; }; - "/sessions": { + "/item": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: { + get?: never; + put?: never; + /** + * /item + * @description Create an Item + */ + post: { parameters: { query?: never; header?: never; @@ -59,30 +69,29 @@ export type paths = { [name: string]: unknown; }; content: { - "application/json; charset=utf-8": components["schemas"]["Session"][]; + "application/json; charset=utf-8": components["schemas"]["Item"]; }; }; }; }; - put?: never; - post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/sessions/{session_id}": { + "/item/next": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; - post?: never; - delete: { + /** + * /item/next + * @description Suggest next Item Id + */ + get: { parameters: { query?: never; header?: never; @@ -96,23 +105,30 @@ export type paths = { [name: string]: unknown; }; content: { - "application/json; charset=utf-8": components["schemas"]["Session"][]; + "application/json; charset=utf-8": components["schemas"]["ItemIdResponse"]; }; }; }; }; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/user/{id}": { + "/item/{item_id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + /** + * /item/:item_id + * @description Get an Item by `item_id` + */ get: { parameters: { query?: never; @@ -127,26 +143,75 @@ export type paths = { [name: string]: unknown; }; content: { - "application/json; charset=utf-8": components["schemas"]["User"]; + "application/json; charset=utf-8": components["schemas"]["Item"]; }; }; }; }; put?: never; post?: never; - delete?: never; + /** + * /item/:item_id + * @description Delete an Item by `item_id` + */ + delete: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; options?: never; head?: never; - patch?: never; + /** + * /item/:item_id + * @description Edit an Item by `item_id` + * This updates the `name`, `owner_id`, `location_id`, `product_id`, and `media` (linking `"new-media"`, and removing `"removed-media"`) + */ + patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json; charset=utf-8": components["schemas"]["ItemUpdatePayload"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; trace?: never; }; - "/instance/settings": { + "/item/{item_id}/media": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + /** + * /item/:item_id/media + * @description Get all media for an Item by `item_id` + */ get: { parameters: { query?: never; @@ -161,7 +226,7 @@ export type paths = { [name: string]: unknown; }; content: { - "application/json; charset=utf-8": components["schemas"]["InstanceSettings"]; + "application/json; charset=utf-8": number[]; }; }; }; @@ -174,13 +239,17 @@ export type paths = { patch?: never; trace?: never; }; - "/item/{item_id}": { + "/item/{item_id}/logs": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + /** + * /item/:item_id/logs + * @description Get all logs for an Item by `item_id` + */ get: { parameters: { query?: never; @@ -195,14 +264,31 @@ export type paths = { [name: string]: unknown; }; content: { - "application/json; charset=utf-8": components["schemas"]["Item"]; + "application/json; charset=utf-8": components["schemas"]["LogEntry"][]; }; }; }; }; put?: never; post?: never; - delete: { + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/media": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * /media + * @description Get all media + */ + get: { parameters: { query?: never; header?: never; @@ -215,42 +301,53 @@ export type paths = { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json; charset=utf-8": components["schemas"]["Media"][]; + }; }; }; }; - options?: never; - head?: never; - patch: { + put?: never; + /** + * /media + * @description Create a new Media + */ + post: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json; charset=utf-8": components["schemas"]["ItemUpdatePayload"]; - }; - }; + requestBody?: never; responses: { 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json; charset=utf-8": components["schemas"]["Media"]; + }; }; }; }; + delete?: never; + options?: never; + head?: never; + patch?: never; trace?: never; }; - "/item/owned": { + "/media/unassigned": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + /** + * /media/unassigned + * @description Get all unassigned media + */ get: { parameters: { query?: never; @@ -265,7 +362,7 @@ export type paths = { [name: string]: unknown; }; content: { - "application/json; charset=utf-8": components["schemas"]["Item"][]; + "application/json; charset=utf-8": components["schemas"]["Media"][]; }; }; }; @@ -278,16 +375,18 @@ export type paths = { patch?: never; trace?: never; }; - "/item": { + "/media/{media_id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; - post: { + /** + * /media/:media_id + * @description Get a Media by `media_id` + */ + get: { parameters: { query?: never; header?: never; @@ -301,25 +400,18 @@ export type paths = { [name: string]: unknown; }; content: { - "application/json; charset=utf-8": components["schemas"]["Item"]; + "application/json; charset=utf-8": components["schemas"]["Media"]; }; }; }; }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/item/next": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: { + put?: never; + post?: never; + /** + * /media/:media_id + * @description Delete a Media by `media_id` + */ + delete: { parameters: { query?: never; header?: never; @@ -332,27 +424,26 @@ export type paths = { headers: { [name: string]: unknown; }; - content: { - "application/json; charset=utf-8": components["schemas"]["ItemIdResponse"]; - }; + content?: never; }; }; }; - put?: never; - post?: never; - delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/item/{item_id}/media": { + "/media/{media_id}/items": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + /** + * /media/:media_id/items + * @description Get all items linked to a media by `media_id` + */ get: { parameters: { query?: never; @@ -367,7 +458,7 @@ export type paths = { [name: string]: unknown; }; content: { - "application/json; charset=utf-8": number[]; + "application/json; charset=utf-8": components["schemas"]["LinkedItem"][]; }; }; }; @@ -387,6 +478,10 @@ export type paths = { path?: never; cookie?: never; }; + /** + * /search + * @description Search for Items + */ get: { parameters: { query?: never; @@ -414,7 +509,7 @@ export type paths = { patch?: never; trace?: never; }; - "/search/index": { + "/search/reindex": { parameters: { query?: never; header?: never; @@ -423,6 +518,10 @@ export type paths = { }; get?: never; put?: never; + /** + * /search/reindex + * @description Reindex all Items + */ post: { parameters: { query?: never; @@ -453,6 +552,10 @@ export type paths = { path?: never; cookie?: never; }; + /** + * /search/tasks + * @description Get all Search Tasks + */ get: { parameters: { query?: never; @@ -480,7 +583,7 @@ export type paths = { patch?: never; trace?: never; }; - "/search/tasks/{external_task_id}": { + "/search/tasks/{task_id}": { parameters: { query?: never; header?: never; @@ -488,6 +591,10 @@ export type paths = { cookie?: never; }; get?: never; + /** + * /search/tasks/:task_id + * @description Refresh a Search Task by `task_id` + */ put: { parameters: { query?: never; @@ -514,13 +621,17 @@ export type paths = { patch?: never; trace?: never; }; - "/media/{media_id}": { + "/me": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + /** + * /me + * @description Get the current user + */ get: { parameters: { query?: never; @@ -535,14 +646,31 @@ export type paths = { [name: string]: unknown; }; content: { - "application/json; charset=utf-8": components["schemas"]["Media"]; + "application/json; charset=utf-8": components["schemas"]["User"]; }; }; }; }; put?: never; post?: never; - delete: { + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/user/{user_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * /user/:user_id + * @description Get a User by `user_id` + */ + get: { parameters: { query?: never; header?: never; @@ -555,22 +683,31 @@ export type paths = { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json; charset=utf-8": components["schemas"]["User"]; + }; }; }; }; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/media": { + "/sessions": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + /** + * /sessions + * @description Get all sessions for the current user + */ get: { parameters: { query?: never; @@ -585,13 +722,34 @@ export type paths = { [name: string]: unknown; }; content: { - "application/json; charset=utf-8": components["schemas"]["Media"][]; + "application/json; charset=utf-8": components["schemas"]["Session"][]; }; }; }; }; put?: never; - post: { + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sessions/{session_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * /sessions/:session_id + * @description Delete a Session by `session_id` + */ + delete: { parameters: { query?: never; header?: never; @@ -605,11 +763,48 @@ export type paths = { [name: string]: unknown; }; content: { - "application/json; charset=utf-8": components["schemas"]["Media"]; + "application/json; charset=utf-8": components["schemas"]["Session"][]; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/instance/settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * /instance/settings + * @description Get the instance settings + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json; charset=utf-8": components["schemas"]["InstanceSettings"]; }; }; }; }; + put?: never; + post?: never; delete?: never; options?: never; head?: never; @@ -659,6 +854,30 @@ export type components = { product_id?: number; media?: components["schemas"]["ItemUpdateMediaPayload"][]; }; + LinkedItem: { + item_id: string; + name: string; + /** + * Format: int32 + * @description The first media we find linked to this item + */ + media_id: number; + }; + /** @description Represents a log entry + * When an action is performed on a resource, a log entry is created + * This resource can then be queried by user, resource_type, (resource_type + resource_id), or action */ + LogEntry: { + /** Format: int32 */ + log_id: number; + resource_type: string; + resource_id: string; + /** Format: int32 */ + user_id: number; + action: string; + data: string; + /** Format: date-time */ + created_at: string; + }; Media: { /** Format: int32 */ media_id: number; diff --git a/web/src/components/item/logs/ItemLogSection.tsx b/web/src/components/item/logs/ItemLogSection.tsx new file mode 100644 index 0000000..a01dc00 --- /dev/null +++ b/web/src/components/item/logs/ItemLogSection.tsx @@ -0,0 +1,65 @@ +import TimeAgo from 'javascript-time-ago'; +import en from 'javascript-time-ago/locale/en'; +import { match } from 'ts-pattern'; + +import { ApiLogResponse, useApiItemLogs } from '@/api/item'; +import { UserProfile } from '@/components/UserProfile'; + +export type ApiLogEntry = ApiLogResponse[number]; + +TimeAgo.addDefaultLocale(en); +const timeAgo = new TimeAgo('en-US'); + +const ItemLogEntry = ({ log }: { log: ApiLogEntry }) => { + const created_ago = timeAgo.format(new Date(log.created_at)); + + return ( +
  • +
    + {match({ action: log.action }) + .with({ action: 'create' }, () => ( + <> + Created by + + + )) + .with({ action: 'edit' }, () => ( + <> + Edited by + + + )) + .otherwise(() => log.action)} +
    +
    {created_ago}
    + {!['create', 'edit'].includes(log.action) && ( +
    + +
    + )} +
  • + ); +}; + +export const ItemLogSection = ({ item_id }: { item_id: string }) => { + const { data: logs } = useApiItemLogs(item_id); + + return ( +
    +

    Logs

    +
    +
      + {logs?.map((log) => ( + + ))} +
    +
    +
    + ); +}; diff --git a/web/src/index.css b/web/src/index.css index 130b6ad..236509d 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -57,6 +57,10 @@ @apply text-2xl font-semibold; } +.h2 { + @apply text-xl font-semibold px-2 py-1.5; +} + /* reset */ a { all: unset; diff --git a/web/src/routes/item/$itemId/index.tsx b/web/src/routes/item/$itemId/index.tsx index 0b8b0fd..03a8413 100644 --- a/web/src/routes/item/$itemId/index.tsx +++ b/web/src/routes/item/$itemId/index.tsx @@ -7,6 +7,7 @@ import { import { formatId, getInstanceSettings } from '@/api/instance_settings'; import { useApiItemById, useApiItemMedia } from '@/api/item'; +import { ItemLogSection } from '@/components/item/logs/ItemLogSection'; import { MediaGallery } from '@/components/media/MediaGallery'; import { Button } from '@/components/ui/Button'; import { UnauthorizedResourceModal } from '@/components/Unauthorized'; @@ -69,6 +70,7 @@ export const Route = createFileRoute('/item/$itemId/')({

    {item?.product_id}

    + ); },