docs: implementation plan for leptos messages phase 3
This commit is contained in:
@@ -0,0 +1,556 @@
|
||||
# Phase 3: Messages Feature — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement the complete Messages panel — message list with infinite scroll, user-grouped cards, Discord emoji rendering, AI status filtering, full-text search, image grid, reanalyze actions, and WS-driven real-time updates.
|
||||
|
||||
**Architecture:** Leptos signals + Resources for state and data fetching. `web-sys` `IntersectionObserver` for infinite scroll sentinel. `ws::context::WsContext` drives real-time message_created/updated/deleted/analyzed events. Components follow the existing `ui/*` + `layout/*` patterns. CSS classes defined in Phase 2's `app.css`.
|
||||
|
||||
**Tech Stack:** Leptos 0.7 CSR, `gloo-net` fetch, `web-sys` (IntersectionObserver, console), `serde`/`serde_json`, `lucide-leptos` icons, plain CSS.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Branch: `leptos-rewrite` at `/mnt/code/bete/.worktrees/leptos-rewrite/`
|
||||
- Use `use leptos::prelude::*;` (NOT `use leptos::*;`)
|
||||
- Combine duplicate `class=` attributes into single `format!()` strings
|
||||
- `#[serde(rename_all = "camelCase")]` on structs where backend sends camelCase
|
||||
- All timestamps are `i64` (millis since epoch), not strings
|
||||
- Rust types must match the JSON wire format from backend exactly
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
services/frontend-leptos/frontend/src/
|
||||
├── features/
|
||||
│ ├── mod.rs # mod messages;
|
||||
│ └── messages/
|
||||
│ ├── mod.rs # MessagesPanel component
|
||||
│ ├── components/
|
||||
│ │ ├── mod.rs # mod message_feed message_card image_grid;
|
||||
│ │ ├── message_feed.rs # Infinite scroll, user grouping
|
||||
│ │ ├── message_card.rs # User-grouped message card
|
||||
│ │ └── image_grid.rs # Masonry-like image grid
|
||||
│ └── hooks/
|
||||
│ ├── mod.rs # mod use_messages;
|
||||
│ └── use_messages.rs # Data fetching, pagination, reanalyze
|
||||
```
|
||||
|
||||
**Files to modify:**
|
||||
- `services/frontend-leptos/frontend/src/lib.rs` — add `pub mod features;`
|
||||
- `services/frontend-leptos/frontend/src/app.rs` — wire `MessagesPanel` as default tab content
|
||||
- `services/frontend-leptos/frontend/src/app.css` — add message-specific CSS classes
|
||||
|
||||
**Files to create (8 new):**
|
||||
- `services/frontend-leptos/frontend/src/features/mod.rs`
|
||||
- `services/frontend-leptos/frontend/src/features/messages/mod.rs`
|
||||
- `services/frontend-leptos/frontend/src/features/messages/components/mod.rs`
|
||||
- `services/frontend-leptos/frontend/src/features/messages/components/message_feed.rs`
|
||||
- `services/frontend-leptos/frontend/src/features/messages/components/message_card.rs`
|
||||
- `services/frontend-leptos/frontend/src/features/messages/components/image_grid.rs`
|
||||
- `services/frontend-leptos/frontend/src/features/messages/hooks/mod.rs`
|
||||
- `services/frontend-leptos/frontend/src/features/messages/hooks/use_messages.rs`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Module skeleton + use_messages hook
|
||||
|
||||
**Files:**
|
||||
- Create: `services/frontend-leptos/frontend/src/features/mod.rs`
|
||||
- Create: `services/frontend-leptos/frontend/src/features/messages/mod.rs`
|
||||
- Create: `services/frontend-leptos/frontend/src/features/messages/components/mod.rs`
|
||||
- Create: `services/frontend-leptos/frontend/src/features/messages/hooks/mod.rs`
|
||||
- Create: `services/frontend-leptos/frontend/src/features/messages/hooks/use_messages.rs`
|
||||
- Modify: `services/frontend-leptos/frontend/src/lib.rs` — add `pub mod features;`
|
||||
- Modify: `services/frontend-leptos/frontend/src/features/messages/mod.rs` — add placeholder `MessagesPanel` component
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `shared_types::message::{MessageRecord, PageResult}` from shared-types crate, `api::messages::{get_messages, search_messages, reanalyze_message, reanalyze_batch}` from API client
|
||||
- Produces: `MessagesState` struct returned by `use_messages()` that later tasks wire into `MessagesPanel`
|
||||
|
||||
**Step 1: Create module files**
|
||||
|
||||
`services/frontend-leptos/frontend/src/features/mod.rs`:
|
||||
```rust
|
||||
pub mod messages;
|
||||
```
|
||||
|
||||
`services/frontend-leptos/frontend/src/features/messages/hooks/mod.rs`:
|
||||
```rust
|
||||
pub mod use_messages;
|
||||
```
|
||||
|
||||
`services/frontend-leptos/frontend/src/features/messages/components/mod.rs`:
|
||||
```rust
|
||||
pub mod message_feed;
|
||||
pub mod message_card;
|
||||
pub mod image_grid;
|
||||
```
|
||||
|
||||
`services/frontend-leptos/frontend/src/features/messages/mod.rs` (placeholder for now):
|
||||
```rust
|
||||
use leptos::prelude::*;
|
||||
|
||||
pub mod components;
|
||||
pub mod hooks;
|
||||
|
||||
#[component]
|
||||
pub fn MessagesPanel() -> impl IntoView {
|
||||
view! {
|
||||
<div>"Messages Panel (loading...)"</div>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Create use_messages hook**
|
||||
|
||||
`services/frontend-leptos/frontend/src/features/messages/hooks/use_messages.rs`:
|
||||
|
||||
This hook ports the React `useMessages` logic. It manages:
|
||||
- `messages: RwSignal<Vec<MessageRecord>>` — the message list, sorted by created_at desc
|
||||
- `loading: Signal<bool>` — loading state
|
||||
- `loading_more: RwSignal<bool>` — infinite scroll loading
|
||||
- `cursor: RwSignal<Option<String>>` — pagination cursor
|
||||
- `has_more: Signal<bool>` — derived from cursor
|
||||
- `error: RwSignal<Option<String>>` — last error message
|
||||
- `current_guild: RwSignal<Option<String>>` — current guild ID
|
||||
- `fetch_messages(guild_id)` — initial fetch (replaces messages)
|
||||
- `load_more()` — append next page
|
||||
- `reanalyze(id)` — optimistic status flip + API call + revert on failure
|
||||
- `reanalyze_all_errors()` — batch reanalyze error messages
|
||||
|
||||
The `mergeMessages` function ported from React:
|
||||
```rust
|
||||
pub fn merge_messages(current: &[MessageRecord], incoming: &[MessageRecord]) -> Vec<MessageRecord> {
|
||||
let mut by_id: std::collections::HashMap<&str, &MessageRecord> = current.iter().map(|m| (m.id.as_str(), m)).collect();
|
||||
for msg in incoming {
|
||||
by_id.insert(msg.id.as_str(), msg);
|
||||
}
|
||||
let mut merged: Vec<MessageRecord> = by_id.into_values().cloned().collect();
|
||||
merged.sort_by(|a, b| b.created_at.cmp(&a.created_at).then_with(|| b.id.cmp(&a.id)));
|
||||
merged
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Verify compilation**
|
||||
|
||||
Run: `cargo check --manifest-path services/frontend-leptos/Cargo.toml` from worktree root.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add services/frontend-leptos/frontend/src/features/ services/frontend-leptos/frontend/src/lib.rs
|
||||
git commit -m "feat(leptos): messages module skeleton + use_messages hook"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: MessageFeed + message grouping
|
||||
|
||||
**Files:**
|
||||
- Create: `services/frontend-leptos/frontend/src/features/messages/components/message_feed.rs`
|
||||
- Modify: `services/frontend-leptos/frontend/src/features/messages/components/mod.rs` — already done in Task 1
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `MessageRecord` from shared-types, `message_card::MessageCard` and `message_card::MessageCardSkeleton` from next task
|
||||
- Produces: `MessageFeed` component with props: `messages: Vec<MessageRecord>`, `on_reanalyze: Callback<String>`, `empty_text: String`, `has_more: bool`, `loading_more: bool`, `on_load_more: Callback<()>`, `loading: bool`
|
||||
|
||||
**Key logic (ported from React `MessageFeed`):**
|
||||
|
||||
Group messages by same user within 5-minute window:
|
||||
```rust
|
||||
const GROUP_WINDOW_MS: i64 = 5 * 60 * 1000;
|
||||
|
||||
fn group_messages(messages: &[MessageRecord]) -> Vec<Vec<MessageRecord>> {
|
||||
let mut groups: Vec<Vec<MessageRecord>> = Vec::new();
|
||||
for msg in messages {
|
||||
if let Some(last_group) = groups.last_mut() {
|
||||
let same_user = last_group.first().map(|m| m.user_id == msg.user_id).unwrap_or(false);
|
||||
let same_window = last_group.last().map(|m| (m.created_at - msg.created_at).abs() < GROUP_WINDOW_MS).unwrap_or(false);
|
||||
if same_user && same_window {
|
||||
last_group.push(msg.clone());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
groups.push(vec![msg.clone()]);
|
||||
}
|
||||
groups
|
||||
}
|
||||
```
|
||||
|
||||
**Infinite scroll sentinel (IntersectionObserver):**
|
||||
|
||||
In Leptos, use `web_sys::IntersectionObserver` via `create_effect` + `on_cleanup`. Create a sentinel `<div>` at the bottom of the list. When it intersects, call `on_load_more`.
|
||||
|
||||
```rust
|
||||
let sentinel_ref = create_node_ref::<html::Div>();
|
||||
|
||||
create_effect(move |_| {
|
||||
let Some(node) = sentinel_ref.get() else { return };
|
||||
let callback = Closure::<dyn Fn(Vec<js_sys::Object>)>::new(move |entries: Vec<js_sys::Object>| {
|
||||
// entries.is_intersecting → call on_load_more
|
||||
});
|
||||
let observer = IntersectionObserver::new(callback.as_ref().unchecked_ref()).unwrap();
|
||||
observer.observe(&node);
|
||||
// cleanup: observer.disconnect() on on_cleanup
|
||||
});
|
||||
```
|
||||
|
||||
**Step 1: Create message_feed.rs** with grouping + sentinel + skeleton
|
||||
|
||||
**Step 2: Verify compilation**
|
||||
|
||||
**Step 3: Commit**
|
||||
```bash
|
||||
git commit -m "feat(leptos): MessageFeed with user grouping and infinite scroll"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: MessageCard — message rendering
|
||||
|
||||
**Files:**
|
||||
- Create: `services/frontend-leptos/frontend/src/features/messages/components/message_card.rs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `MessageRecord`, `api::messages::get_message_detail` (for replied-to messages), `ui::status_badge::StatusBadge`
|
||||
- Produces: `MessageCard` component (props: `messages: Vec<MessageRecord>`, `on_reanalyze: Callback<String>`), `MessageCardSkeleton` component
|
||||
|
||||
**Key logic (ported from React `MessageCard.tsx`, 561 lines):**
|
||||
|
||||
1. **Header:** Avatar (img from `avatar_url`), username, channel/thread info, time ago
|
||||
2. **Content:** Render message with custom Discord emoji substitution (`<a:name:id>` → CDN `<img>`)
|
||||
3. **Edit/delete indicators:** Pencil icon if `edited_at` present, Trash2 icon if `deleted_at` present
|
||||
4. **Attachments:** Images, videos, other files with download links
|
||||
5. **Sticker:** Display sticker image if metadata contains stickers
|
||||
6. **Embeds:** Show title + thumbnail from metadata embeds
|
||||
7. **Reply context:** If `metadata.channel.thread_id` present, fetch the parent message
|
||||
8. **AI analysis box:** StatusBadge, severity badge, confidence bar, categories, recommended action, error message, reanalyze button
|
||||
|
||||
**Discord custom emoji regex:**
|
||||
```rust
|
||||
use regex::Regex;
|
||||
|
||||
fn render_content(content: &str) -> Vec<HtmlElement> {
|
||||
// <a:name:id> → animated GIF
|
||||
// <:name:id> → static PNG
|
||||
// URL: https://cdn.discordapp.com/emojis/{id}.{ext}?size=128
|
||||
}
|
||||
```
|
||||
|
||||
Note: `regex` crate needs to be added to `Cargo.toml` as a dependency.
|
||||
|
||||
**Step 1: Add `regex = "1"` to frontend Cargo.toml**
|
||||
|
||||
**Step 2: Create message_card.rs** with all sub-sections
|
||||
|
||||
**Step 3: Verify compilation**
|
||||
|
||||
**Step 4: Commit**
|
||||
```bash
|
||||
git commit -m "feat(leptos): MessageCard with Discord emoji, attachments, AI analysis display"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: ImageGrid
|
||||
|
||||
**Files:**
|
||||
- Create: `services/frontend-leptos/frontend/src/features/messages/components/image_grid.rs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `MessageRecord` (extracts image URLs from attachments, stickers, embeds)
|
||||
- Produces: `ImageGrid` component (props: `messages: Vec<MessageRecord>`)
|
||||
|
||||
**Key logic (ported from React `ImageGrid.tsx`, 132 lines):**
|
||||
|
||||
Extract images from message metadata:
|
||||
```rust
|
||||
fn extract_images(messages: &[MessageRecord]) -> Vec<String> {
|
||||
let mut urls = Vec::new();
|
||||
for msg in messages {
|
||||
if let Some(meta) = &msg.metadata {
|
||||
// attachments with image MIME
|
||||
if let Some(atts) = &meta.attachments {
|
||||
for att in atts {
|
||||
if let Some(ct) = &att.content_type {
|
||||
if ct.starts_with("image/") || ct.starts_with("video/") {
|
||||
urls.push(att.url.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// sticker images
|
||||
if let Some(stickers) = &meta.stickers {
|
||||
for s in stickers {
|
||||
if let Some(url) = &s.url {
|
||||
urls.push(url.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// embed thumbnails/images
|
||||
if let Some(embeds) = &meta.embeds {
|
||||
for e in embeds {
|
||||
if let Some(img) = &e.image { urls.push(img.url.clone()); }
|
||||
if let Some(thumb) = &e.thumbnail { urls.push(thumb.url.clone()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
urls.sort();
|
||||
urls.dedup();
|
||||
urls
|
||||
}
|
||||
```
|
||||
|
||||
Render as CSS grid of `<img>` elements with click-to-fullsize.
|
||||
|
||||
**Step 1: Create image_grid.rs**
|
||||
|
||||
**Step 2: Verify compilation**
|
||||
|
||||
**Step 3: Commit**
|
||||
```bash
|
||||
git commit -m "feat(leptos): ImageGrid component for message media"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: MessagesPanel — full composition
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/frontend-leptos/frontend/src/features/messages/mod.rs` — replace placeholder with full panel
|
||||
- Modify: `services/frontend-leptos/frontend/src/app.rs` — wire `MessagesPanel` as default tab content
|
||||
- Modify: `services/frontend-leptos/frontend/src/app.css` — add message-specific CSS classes
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `use_messages::use_messages`, `MessageFeed`, `ImageGrid`, `ui::tabs::*`, `ui::badge::Badge`, `ui::input::Input`, `ui::button::Button`, `ui::card::*`, `app::UiContext`, `app::AuthContext`, `ws::context::WsContext`
|
||||
|
||||
**Key logic (ported from React `MessagesPanel.tsx`, 316 lines):**
|
||||
|
||||
1. **State:** `ai_filter`, `search_query`, `search_results`, `show_search`, `view_tab` ("all" | "images"), `retrying_all`, `retried_count`
|
||||
2. **Search:** Debounced search via `api::messages::search_messages`
|
||||
3. **Stats badges:** total, clean, flagged, error, pending, deleted, edited
|
||||
4. **AI filter chips:** all, analyzed, clean, flagged, error, pending
|
||||
5. **View tabs:** All (MessageFeed) | Images (ImageGrid)
|
||||
6. **WS integration:** `message_created` → prepend, `message_updated` → merge, `message_deleted` → remove, `message_analyzed` → update fields
|
||||
7. **Reanalyze:** Individual + batch error reanalyze
|
||||
|
||||
**CSS classes to add in `app.css`:**
|
||||
```css
|
||||
.filter-chip { /* pill-shaped filter button */ }
|
||||
.filter-chip.active { /* active state */ }
|
||||
.message-stats { /* flex row of stat badges */ }
|
||||
.search-bar { /* search input with icon */ }
|
||||
.messages-panel { /* main container */ }
|
||||
```
|
||||
|
||||
**Step 1: Create message-specific CSS**
|
||||
|
||||
**Step 2: Replace MessagesPanel with full implementation**
|
||||
|
||||
**Step 3: Wire into app.rs as default tab**
|
||||
|
||||
**Step 4: Add WS event handlers for real-time message updates**
|
||||
|
||||
**Step 5: Verify build**
|
||||
```bash
|
||||
cd services/frontend-leptos/frontend && /home/asephs/.cargo/bin/trunk build --release
|
||||
```
|
||||
|
||||
**Step 6: Commit**
|
||||
```bash
|
||||
git commit -m "feat(leptos): MessagesPanel with search, filters, real-time WS updates"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task Dependency Graph
|
||||
|
||||
```
|
||||
Task 1 (skeleton + hook)
|
||||
├→ Task 2 (MessageFeed)
|
||||
│ └→ Task 3 (MessageCard) ──┐
|
||||
└→ Task 4 (ImageGrid) ─────────┤
|
||||
└→ Task 5 (MessagesPanel composition)
|
||||
```
|
||||
|
||||
- Tasks 2 and 4 can be dispatched **in parallel** (independent components)
|
||||
- Task 3 depends on Task 2 (MessageFeed imports MessageCard)
|
||||
- Task 5 depends on all of Tasks 2, 3, 4
|
||||
|
||||
---
|
||||
|
||||
## CSS Classes Needed (append to `app.css`)
|
||||
|
||||
```css
|
||||
/* ── Messages Panel ──────────────────────────────────── */
|
||||
.messages-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.message-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.filter-chip:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-overlay);
|
||||
}
|
||||
.filter-chip.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.search-row .input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
/* ── Message Card ────────────────────────────────────── */
|
||||
.message-card {
|
||||
background: var(--surface-base);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.message-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
.message-card-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.message-card-username {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.message-card-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.message-card-body {
|
||||
padding: var(--space-4) var(--space-6);
|
||||
}
|
||||
.message-row {
|
||||
padding: var(--space-2) 0;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
.message-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.message-timestamp {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-tertiary);
|
||||
min-width: 48px;
|
||||
}
|
||||
.message-content {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
.message-content .custom-emoji {
|
||||
display: inline-block;
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
vertical-align: middle;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* ── AI Analysis Box ─────────────────────────────────── */
|
||||
.ai-analysis-box {
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
background: var(--surface-overlay);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--surface-border);
|
||||
}
|
||||
.ai-analysis-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
.ai-analysis-text {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ai-confidence-bar {
|
||||
height: 4px;
|
||||
background: var(--surface-border);
|
||||
border-radius: 2px;
|
||||
margin-top: var(--space-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ai-confidence-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--color-primary);
|
||||
transition: width var(--transition-normal);
|
||||
}
|
||||
|
||||
/* ── Image Grid ──────────────────────────────────────── */
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.image-grid-item {
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
.image-grid-item:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.image-grid-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Reference Files
|
||||
|
||||
- React MessagesPanel: `services/frontend/src/features/messages/index.tsx` (316 lines)
|
||||
- React useMessages: `services/frontend/src/features/messages/hooks/useMessages.ts` (162 lines)
|
||||
- React MessageFeed: `services/frontend/src/features/messages/components/MessageFeed.tsx` (120 lines)
|
||||
- React MessageCard: `services/frontend/src/features/messages/components/MessageCard.tsx` (561 lines)
|
||||
- React ImageGrid: `services/frontend/src/features/messages/components/ImageGrid.tsx` (132 lines)
|
||||
- Leptos shared-types: `services/frontend-leptos/shared-types/src/message.rs`
|
||||
- Leptos API client: `services/frontend-leptos/frontend/src/api/messages.rs`
|
||||
- Leptos WS context: `services/frontend-leptos/frontend/src/ws/context.rs`
|
||||
- Leptos app shell: `services/frontend-leptos/frontend/src/app.rs`
|
||||
Reference in New Issue
Block a user