feat: store AP note extensions in JSONB and render movies-diary posts as rich cards
This commit is contained in:
@@ -14,6 +14,36 @@ use domain::ports::{EventPublisher, TagRepository};
|
|||||||
use domain::value_objects::UserId;
|
use domain::value_objects::UserId;
|
||||||
use k_ap::ApObjectHandler;
|
use k_ap::ApObjectHandler;
|
||||||
|
|
||||||
|
fn extract_note_extensions(obj: &serde_json::Value) -> Option<serde_json::Value> {
|
||||||
|
const STANDARD: &[&str] = &[
|
||||||
|
"type",
|
||||||
|
"id",
|
||||||
|
"attributedTo",
|
||||||
|
"content",
|
||||||
|
"published",
|
||||||
|
"to",
|
||||||
|
"cc",
|
||||||
|
"inReplyTo",
|
||||||
|
"sensitive",
|
||||||
|
"summary",
|
||||||
|
"tag",
|
||||||
|
"url",
|
||||||
|
"@context",
|
||||||
|
"mediaType",
|
||||||
|
];
|
||||||
|
let extensions: serde_json::Map<String, serde_json::Value> = obj
|
||||||
|
.as_object()?
|
||||||
|
.iter()
|
||||||
|
.filter(|(k, _)| !STANDARD.contains(&k.as_str()))
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.collect();
|
||||||
|
if extensions.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(serde_json::Value::Object(extensions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ThoughtsObjectHandler {
|
pub struct ThoughtsObjectHandler {
|
||||||
repo: Arc<dyn ActivityPubRepository>,
|
repo: Arc<dyn ActivityPubRepository>,
|
||||||
urls: ThoughtsUrls,
|
urls: ThoughtsUrls,
|
||||||
@@ -118,6 +148,7 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
actor_url: &Url,
|
actor_url: &Url,
|
||||||
object: serde_json::Value,
|
object: serde_json::Value,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
let note_extensions = extract_note_extensions(&object);
|
||||||
let note: ThoughtNote = serde_json::from_value(object)?;
|
let note: ThoughtNote = serde_json::from_value(object)?;
|
||||||
let author_id = self
|
let author_id = self
|
||||||
.repo
|
.repo
|
||||||
@@ -153,6 +184,7 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
content_warning: note.summary,
|
content_warning: note.summary,
|
||||||
visibility,
|
visibility,
|
||||||
in_reply_to: note.in_reply_to.as_ref().map(|u| u.as_str()),
|
in_reply_to: note.in_reply_to.as_ref().map(|u| u.as_str()),
|
||||||
|
note_extensions,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
@@ -408,3 +440,46 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
.map_err(|e| anyhow!("{e}"))
|
.map_err(|e| anyhow!("{e}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod extract_tests {
|
||||||
|
use super::extract_note_extensions;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_non_standard_fields() {
|
||||||
|
let obj = serde_json::json!({
|
||||||
|
"type": "Note",
|
||||||
|
"id": "https://example.com/notes/1",
|
||||||
|
"content": "hello",
|
||||||
|
"published": "2025-01-01T00:00:00Z",
|
||||||
|
"movieTitle": "Dune",
|
||||||
|
"rating": 5,
|
||||||
|
"posterUrl": "https://example.com/poster.jpg"
|
||||||
|
});
|
||||||
|
let ext = extract_note_extensions(&obj).unwrap();
|
||||||
|
assert_eq!(ext["movieTitle"], "Dune");
|
||||||
|
assert_eq!(ext["rating"], 5);
|
||||||
|
assert_eq!(ext["posterUrl"], "https://example.com/poster.jpg");
|
||||||
|
assert!(ext.get("type").is_none());
|
||||||
|
assert!(ext.get("content").is_none());
|
||||||
|
assert!(ext.get("id").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_none_for_standard_only_note() {
|
||||||
|
let obj = serde_json::json!({
|
||||||
|
"type": "Note",
|
||||||
|
"content": "hello",
|
||||||
|
"published": "2025-01-01T00:00:00Z",
|
||||||
|
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||||
|
"tag": []
|
||||||
|
});
|
||||||
|
assert!(extract_note_extensions(&obj).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_none_for_non_object() {
|
||||||
|
let obj = serde_json::json!("not an object");
|
||||||
|
assert!(extract_note_extensions(&obj).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub struct AcceptNoteInput<'a> {
|
|||||||
pub content_warning: Option<String>,
|
pub content_warning: Option<String>,
|
||||||
pub visibility: &'a str,
|
pub visibility: &'a str,
|
||||||
pub in_reply_to: Option<&'a str>,
|
pub in_reply_to: Option<&'a str>,
|
||||||
|
pub note_extensions: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AP-protocol endpoints for a locally-stored user (local or interned remote).
|
/// AP-protocol endpoints for a locally-stored user (local or interned remote).
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ sqlx = { workspace = true }
|
|||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ struct FeedRow {
|
|||||||
reply_count: i64,
|
reply_count: i64,
|
||||||
liked_by_viewer: bool,
|
liked_by_viewer: bool,
|
||||||
boosted_by_viewer: bool,
|
boosted_by_viewer: bool,
|
||||||
|
note_extensions: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
||||||
@@ -67,7 +68,7 @@ fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
|||||||
t.id AS thought_id, t.user_id AS t_user_id, t.content,\n\
|
t.id AS thought_id, t.user_id AS t_user_id, t.content,\n\
|
||||||
t.in_reply_to_id,\n\
|
t.in_reply_to_id,\n\
|
||||||
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,\n\
|
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,\n\
|
||||||
t.created_at AS thought_created_at, t.updated_at,\n\
|
t.created_at AS thought_created_at, t.updated_at, t.note_extensions,\n\
|
||||||
u.id AS author_id, u.username, u.email, u.password_hash,\n\
|
u.id AS author_id, u.username, u.email, u.password_hash,\n\
|
||||||
u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,\n\
|
u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,\n\
|
||||||
u.local AS author_local,\n\
|
u.local AS author_local,\n\
|
||||||
@@ -92,6 +93,7 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
|
|||||||
local: r.t_local,
|
local: r.t_local,
|
||||||
created_at: r.thought_created_at,
|
created_at: r.thought_created_at,
|
||||||
updated_at: r.updated_at,
|
updated_at: r.updated_at,
|
||||||
|
note_extensions: r.note_extensions,
|
||||||
};
|
};
|
||||||
let author = User {
|
let author = User {
|
||||||
id: UserId::from_uuid(r.author_id),
|
id: UserId::from_uuid(r.author_id),
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE thoughts ADD COLUMN note_extensions JSONB;
|
||||||
@@ -65,6 +65,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
local: true,
|
local: true,
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
updated_at: r.updated_at,
|
updated_at: r.updated_at,
|
||||||
|
note_extensions: None,
|
||||||
},
|
},
|
||||||
author_username: Username::from_trusted(r.username),
|
author_username: Username::from_trusted(r.username),
|
||||||
})
|
})
|
||||||
@@ -130,6 +131,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
local: true,
|
local: true,
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
updated_at: r.updated_at,
|
updated_at: r.updated_at,
|
||||||
|
note_extensions: None,
|
||||||
},
|
},
|
||||||
author_username: Username::from_trusted(r.username),
|
author_username: Username::from_trusted(r.username),
|
||||||
})
|
})
|
||||||
@@ -220,6 +222,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
content_warning,
|
content_warning,
|
||||||
visibility,
|
visibility,
|
||||||
in_reply_to,
|
in_reply_to,
|
||||||
|
note_extensions,
|
||||||
} = input;
|
} = input;
|
||||||
let capped: String = content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
|
let capped: String = content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
|
||||||
let (in_reply_to_id, in_reply_to_url) = match in_reply_to {
|
let (in_reply_to_id, in_reply_to_url) = match in_reply_to {
|
||||||
@@ -236,8 +239,8 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
None => (None, None),
|
None => (None, None),
|
||||||
};
|
};
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO thoughts(id,user_id,content,ap_id,visibility,sensitive,local,content_warning,created_at,in_reply_to_id,in_reply_to_url)
|
"INSERT INTO thoughts(id,user_id,content,ap_id,visibility,sensitive,local,content_warning,created_at,in_reply_to_id,in_reply_to_url,note_extensions)
|
||||||
VALUES($1,$2,$3,$4,$8,$5,false,$6,$7,$9,$10) ON CONFLICT(ap_id) DO NOTHING",
|
VALUES($1,$2,$3,$4,$8,$5,false,$6,$7,$9,$10,$11) ON CONFLICT(ap_id) DO NOTHING",
|
||||||
)
|
)
|
||||||
.bind(uuid::Uuid::new_v4())
|
.bind(uuid::Uuid::new_v4())
|
||||||
.bind(author_id.as_uuid())
|
.bind(author_id.as_uuid())
|
||||||
@@ -249,6 +252,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
.bind(visibility)
|
.bind(visibility)
|
||||||
.bind(in_reply_to_id)
|
.bind(in_reply_to_id)
|
||||||
.bind(&in_reply_to_url)
|
.bind(&in_reply_to_url)
|
||||||
|
.bind(note_extensions)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ async fn accept_and_retract_note(pool: sqlx::PgPool) {
|
|||||||
content_warning: None,
|
content_warning: None,
|
||||||
visibility: "public",
|
visibility: "public",
|
||||||
in_reply_to: None,
|
in_reply_to: None,
|
||||||
|
note_extensions: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -55,6 +56,7 @@ async fn accept_note_returns_thought_id(pool: sqlx::PgPool) {
|
|||||||
content_warning: None,
|
content_warning: None,
|
||||||
visibility: "public",
|
visibility: "public",
|
||||||
in_reply_to: None,
|
in_reply_to: None,
|
||||||
|
note_extensions: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ struct FeedRow {
|
|||||||
t_local: bool,
|
t_local: bool,
|
||||||
thought_created_at: DateTime<Utc>,
|
thought_created_at: DateTime<Utc>,
|
||||||
updated_at: Option<DateTime<Utc>>,
|
updated_at: Option<DateTime<Utc>>,
|
||||||
|
note_extensions: Option<serde_json::Value>,
|
||||||
author_id: uuid::Uuid,
|
author_id: uuid::Uuid,
|
||||||
username: String,
|
username: String,
|
||||||
email: String,
|
email: String,
|
||||||
@@ -82,6 +83,7 @@ fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
|||||||
t.in_reply_to_id,
|
t.in_reply_to_id,
|
||||||
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
|
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
|
||||||
t.created_at AS thought_created_at, t.updated_at,
|
t.created_at AS thought_created_at, t.updated_at,
|
||||||
|
t.note_extensions,
|
||||||
u.id AS author_id,
|
u.id AS author_id,
|
||||||
CASE WHEN NOT u.local AND ra.handle IS NOT NULL AND ra.handle != ''
|
CASE WHEN NOT u.local AND ra.handle IS NOT NULL AND ra.handle != ''
|
||||||
THEN '@' || ra.handle ||
|
THEN '@' || ra.handle ||
|
||||||
@@ -118,6 +120,7 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
|
|||||||
local: r.t_local,
|
local: r.t_local,
|
||||||
created_at: r.thought_created_at,
|
created_at: r.thought_created_at,
|
||||||
updated_at: r.updated_at,
|
updated_at: r.updated_at,
|
||||||
|
note_extensions: r.note_extensions,
|
||||||
};
|
};
|
||||||
let author = User {
|
let author = User {
|
||||||
id: UserId::from_uuid(r.author_id),
|
id: UserId::from_uuid(r.author_id),
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ pub(crate) struct ThoughtRow {
|
|||||||
pub local: bool,
|
pub local: bool,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: Option<DateTime<Utc>>,
|
pub updated_at: Option<DateTime<Utc>>,
|
||||||
|
pub note_extensions: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<ThoughtRow> for Thought {
|
impl TryFrom<ThoughtRow> for Thought {
|
||||||
@@ -50,12 +51,13 @@ impl TryFrom<ThoughtRow> for Thought {
|
|||||||
local: r.local,
|
local: r.local,
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
updated_at: r.updated_at,
|
updated_at: r.updated_at,
|
||||||
|
note_extensions: r.note_extensions,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const THOUGHT_SELECT: &str =
|
const THOUGHT_SELECT: &str =
|
||||||
"SELECT id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at,updated_at FROM thoughts";
|
"SELECT id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at,updated_at,note_extensions FROM thoughts";
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ThoughtRepository for PgThoughtRepository {
|
impl ThoughtRepository for PgThoughtRepository {
|
||||||
@@ -117,11 +119,11 @@ impl ThoughtRepository for PgThoughtRepository {
|
|||||||
sqlx::query_as::<_, ThoughtRow>(
|
sqlx::query_as::<_, ThoughtRow>(
|
||||||
"WITH RECURSIVE thread AS (
|
"WITH RECURSIVE thread AS (
|
||||||
SELECT id,user_id,content,in_reply_to_id,
|
SELECT id,user_id,content,in_reply_to_id,
|
||||||
visibility,content_warning,sensitive,local,created_at,updated_at
|
visibility,content_warning,sensitive,local,created_at,updated_at,note_extensions
|
||||||
FROM thoughts WHERE id = $1
|
FROM thoughts WHERE id = $1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT t.id,t.user_id,t.content,t.in_reply_to_id,
|
SELECT t.id,t.user_id,t.content,t.in_reply_to_id,
|
||||||
t.visibility,t.content_warning,t.sensitive,t.local,t.created_at,t.updated_at
|
t.visibility,t.content_warning,t.sensitive,t.local,t.created_at,t.updated_at,t.note_extensions
|
||||||
FROM thoughts t JOIN thread ON t.in_reply_to_id = thread.id
|
FROM thoughts t JOIN thread ON t.in_reply_to_id = thread.id
|
||||||
)
|
)
|
||||||
SELECT * FROM thread ORDER BY created_at ASC",
|
SELECT * FROM thread ORDER BY created_at ASC",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
utoipa = { version = "5.5.0", features = ["uuid", "chrono"] }
|
utoipa = { version = "5.5.0", features = ["uuid", "chrono"] }
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ pub struct ThoughtResponse {
|
|||||||
pub boosted_by_viewer: bool,
|
pub boosted_by_viewer: bool,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: Option<DateTime<Utc>>,
|
pub updated_at: Option<DateTime<Utc>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub note_extensions: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, utoipa::ToSchema)]
|
#[derive(Serialize, utoipa::ToSchema)]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ thiserror = { workspace = true }
|
|||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
bytes = { workspace = true }
|
bytes = { workspace = true }
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub struct Thought {
|
|||||||
pub local: bool,
|
pub local: bool,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: Option<DateTime<Utc>>,
|
pub updated_at: Option<DateTime<Utc>>,
|
||||||
|
pub note_extensions: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Visibility {
|
impl Visibility {
|
||||||
@@ -69,6 +70,7 @@ impl Thought {
|
|||||||
local: true,
|
local: true,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
updated_at: None,
|
updated_at: None,
|
||||||
|
note_extensions: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtRespon
|
|||||||
boosted_by_viewer: e.viewer.as_ref().map(|v| v.boosted).unwrap_or(false),
|
boosted_by_viewer: e.viewer.as_ref().map(|v| v.boosted).unwrap_or(false),
|
||||||
created_at: e.thought.created_at,
|
created_at: e.thought.created_at,
|
||||||
updated_at: e.thought.updated_at,
|
updated_at: e.thought.updated_at,
|
||||||
|
note_extensions: e.thought.note_extensions.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
105
thoughts-frontend/components/movie-card.tsx
Normal file
105
thoughts-frontend/components/movie-card.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { User } from "@/lib/api";
|
||||||
|
|
||||||
|
function isSafeImageUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return u.protocol === "https:" || u.protocol === "http:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MovieMeta {
|
||||||
|
movieTitle: string;
|
||||||
|
releaseYear?: number;
|
||||||
|
posterUrl?: string | null;
|
||||||
|
rating?: number;
|
||||||
|
comment?: string | null;
|
||||||
|
watchedAt?: string | null;
|
||||||
|
watchlistEntry?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MovieCardProps {
|
||||||
|
meta: MovieMeta;
|
||||||
|
author: User;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StarRating({ rating, max = 5 }: { rating: number; max?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{Array.from({ length: max }).map((_, i) => (
|
||||||
|
<span key={i} className={i < rating ? "text-yellow-400" : "text-muted-foreground/30"}>
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MovieCard({ meta, author, createdAt }: MovieCardProps) {
|
||||||
|
const isWatchlist = meta.watchlistEntry === true;
|
||||||
|
const year = meta.releaseYear ? ` (${meta.releaseYear})` : "";
|
||||||
|
const watchedDate = meta.watchedAt
|
||||||
|
? new Date(meta.watchedAt).toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-card overflow-hidden">
|
||||||
|
<div className="flex gap-3 p-3">
|
||||||
|
{/* Poster */}
|
||||||
|
<div className="shrink-0 w-16 h-24 rounded overflow-hidden bg-muted">
|
||||||
|
{meta.posterUrl && isSafeImageUrl(meta.posterUrl) ? (
|
||||||
|
<img
|
||||||
|
src={meta.posterUrl}
|
||||||
|
alt={meta.movieTitle}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-xs text-center px-1">
|
||||||
|
No poster
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-sm leading-tight">
|
||||||
|
{meta.movieTitle}
|
||||||
|
{year && <span className="font-normal text-muted-foreground">{year}</span>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isWatchlist ? (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">📋 Want to watch</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{meta.rating !== undefined && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<StarRating rating={meta.rating} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{watchedDate && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Watched {watchedDate}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meta.comment && (
|
||||||
|
<p className="text-sm mt-2 text-foreground/80 line-clamp-3">{meta.comment}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-3 py-2 border-t bg-muted/30 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>@{author.username}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{createdAt.toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { ThoughtForm } from "@/components/thought-form";
|
import { ThoughtForm } from "@/components/thought-form";
|
||||||
|
import { MovieCard } from "@/components/movie-card";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -74,6 +75,17 @@ export function ThoughtCard({
|
|||||||
|
|
||||||
const isAuthor = currentUser?.username === thought.author.username;
|
const isAuthor = currentUser?.username === thought.author.username;
|
||||||
|
|
||||||
|
const meta = thought.noteExtensions as Record<string, unknown> | null | undefined;
|
||||||
|
if (meta?.movieTitle) {
|
||||||
|
return (
|
||||||
|
<MovieCard
|
||||||
|
meta={meta as unknown as Parameters<typeof MovieCard>[0]["meta"]}
|
||||||
|
author={thought.author}
|
||||||
|
createdAt={new Date(thought.createdAt)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
setIsAlertOpen(false);
|
setIsAlertOpen(false);
|
||||||
setDeletingState("shaking");
|
setDeletingState("shaking");
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export const ThoughtSchema = z.object({
|
|||||||
boostedByViewer: z.boolean(),
|
boostedByViewer: z.boolean(),
|
||||||
createdAt: z.coerce.date(),
|
createdAt: z.coerce.date(),
|
||||||
updatedAt: z.coerce.date().nullable(),
|
updatedAt: z.coerce.date().nullable(),
|
||||||
|
noteExtensions: z.record(z.string(), z.unknown()).nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const RegisterSchema = z.object({
|
export const RegisterSchema = z.object({
|
||||||
@@ -116,6 +117,7 @@ export const ThoughtThreadSchema: z.ZodType<{
|
|||||||
boostedByViewer: boolean;
|
boostedByViewer: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
|
noteExtensions?: Record<string, unknown> | null;
|
||||||
replies: ThoughtThread[];
|
replies: ThoughtThread[];
|
||||||
}> = z.object({
|
}> = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
@@ -132,6 +134,7 @@ export const ThoughtThreadSchema: z.ZodType<{
|
|||||||
boostedByViewer: z.boolean(),
|
boostedByViewer: z.boolean(),
|
||||||
createdAt: z.coerce.date(),
|
createdAt: z.coerce.date(),
|
||||||
updatedAt: z.coerce.date().nullable(),
|
updatedAt: z.coerce.date().nullable(),
|
||||||
|
noteExtensions: z.record(z.string(), z.unknown()).nullish(),
|
||||||
replies: z.lazy(() => z.array(ThoughtThreadSchema)),
|
replies: z.lazy(() => z.array(ThoughtThreadSchema)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user