Skip to content

Instantly share code, notes, and snippets.

@matthewharwood
Last active November 12, 2024 00:45
Show Gist options
  • Save matthewharwood/59a4033dec68c1bf7b74dfccc52fb8af to your computer and use it in GitHub Desktop.
Save matthewharwood/59a4033dec68c1bf7b74dfccc52fb8af to your computer and use it in GitHub Desktop.
Example of content blocks
// src/schema_types.rs
#[derive(Debug, Clone)]
pub enum AuthorFieldType {
// Text types
String { max_length: Option<usize> },
Text { rows: Option<usize> },
BlockContent,
// Number types
Number { min: Option<f64>, max: Option<f64> },
// Boolean type
Boolean,
// Date types
DateTime,
Date,
// Media types
Image { accept: Vec<String> },
File { accept: Vec<String> },
// Reference types
Reference { to: Vec<String> },
// Array types
Array { of: Box<AuthorFieldType>, max: Option<usize> },
// Object type
Object { fields: Vec<(String, AuthorFieldType)> },
// Special types
Slug { source: String },
Url { validation: Option<String> },
Email { validation: Option<String> },
Color,
GeoPoint
}
// src/block.rs
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
use sea_orm::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HeroContent {
#[edit(String { max_length: Some(100) })]
pub title: String,
#[edit(Text { rows: Some(3) })]
pub description: String,
#[edit(Image { accept: vec!["image/jpeg", "image/png", "image/webp"] })]
pub background_image: String,
#[edit(BlockContent)]
pub rich_content: Option<serde_json::Value>,
#[edit(Array {
of: Box::new(Object {
fields: vec![
("text", String { max_length: Some(50) }),
("url", Url { validation: None })
]
}),
max: Some(3)
})]
pub cta_buttons: Vec<CallToAction>,
#[edit(GeoPoint)]
pub location: Option<GeoPoint>,
#[edit(Color)]
pub theme_color: String,
#[edit(DateTime)]
pub publish_date: chrono::DateTime<chrono::Utc>,
#[edit(Boolean)]
pub is_featured: bool,
#[edit(Number { min: Some(0.0), max: Some(100.0) })]
pub overlay_opacity: f64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CallToAction {
pub text: String,
pub url: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GeoPoint {
pub lat: f64,
pub lng: f64,
}
// src/admin.rs
#[component]
fn FormUI(content_block: ContentBlock) -> impl IntoView {
let form_fields = match content_block {
ContentBlock::Hero(_) => get_hero_form_fields(),
// Add other content block types here
};
view! {
<form class="space-y-4 p-4">
{form_fields}
</form>
}
}
fn get_hero_form_fields() -> impl IntoView {
view! {
<>
<FormField
field_type=AuthorFieldType::String { max_length: Some(100) }
label="Title"
name="title"
/>
<FormField
field_type=AuthorFieldType::Text { rows: Some(3) }
label="Description"
name="description"
/>
<FormField
field_type=AuthorFieldType::Image {
accept: vec!["image/jpeg".to_string(), "image/png".to_string()]
}
label="Background Image"
name="background_image"
/>
<BlockContentEditor
label="Rich Content"
name="rich_content"
/>
<ArrayField
field_type=AuthorFieldType::Array {
of: Box::new(AuthorFieldType::Object {
fields: vec![
("text".to_string(), AuthorFieldType::String { max_length: Some(50) }),
("url".to_string(), AuthorFieldType::Url { validation: None })
]
}),
max: Some(3)
}
label="CTA Buttons"
name="cta_buttons"
/>
<GeoPointField
label="Location"
name="location"
/>
<ColorPicker
label="Theme Color"
name="theme_color"
/>
<DateTimePicker
label="Publish Date"
name="publish_date"
/>
<Toggle
label="Featured"
name="is_featured"
/>
<RangeInput
label="Overlay Opacity"
name="overlay_opacity"
min=0.0
max=100.0
step=1.0
/>
</>
}
}
#[component]
fn FormField(
field_type: AuthorFieldType,
label: &'static str,
name: &'static str,
) -> impl IntoView {
match field_type {
AuthorFieldType::String { max_length } => view! {
<div class="form-field">
<label class="block text-sm font-medium mb-1">{label}</label>
<input
type="text"
name={name}
maxlength={max_length.map(|l| l.to_string())}
class="w-full p-2 border rounded"
/>
</div>
},
AuthorFieldType::Text { rows } => view! {
<div class="form-field">
<label class="block text-sm font-medium mb-1">{label}</label>
<textarea
name={name}
rows={rows.unwrap_or(3)}
class="w-full p-2 border rounded"
/>
</div>
},
// Implement other field types...
_ => view! { <div>"Unsupported field type"</div> }
}
}
#[component]
fn BlockContentEditor(
label: &'static str,
name: &'static str,
) -> impl IntoView {
view! {
<div class="form-field">
<label class="block text-sm font-medium mb-1">{label}</label>
<div class="border rounded p-2">
// Implement rich text editor component
"Rich Text Editor Placeholder"
</div>
</div>
}
}
#[component]
fn ArrayField(
field_type: AuthorFieldType,
label: &'static str,
name: &'static str,
) -> impl IntoView {
let (items, set_items) = create_signal(vec![]);
view! {
<div class="form-field">
<label class="block text-sm font-medium mb-1">{label}</label>
<div class="space-y-2">
<For
each=move || items.get()
key=|item| item.id.clone()
children=move |item| view! {
<div class="flex items-center space-x-2">
// Render form fields based on array item type
"Array Item Field"
</div>
}
/>
<button
type="button"
class="bg-blue-500 text-white px-4 py-2 rounded"
on:click=move |_| {
// Add new item logic
}
>
"Add Item"
</button>
</div>
</div>
}
}
// Implement other specialized form components:
// - GeoPointField
// - ColorPicker
// - DateTimePicker
// - Toggle
// - RangeInput
// Update your PageEditor component to use FormUI
#[component]
fn PageEditor() -> impl IntoView {
view! {
<div class="container mx-auto p-4">
<h1 class="text-2xl font-bold mb-4">"Page Editor"</h1>
<div class="mb-4">
<a href="/preview" class="text-blue-500 hover:underline">"View Preview"</a>
</div>
<FormUI content_block=ContentBlock::Hero(HeroContent::default())/>
</div>
}
}
// Keep the rest of your components (App, Hero, PagePreview) as they were
// src/server.rs
use leptos::*;
use sqlx::{PgPool, postgres::PgRow, Row};
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum ServerError {
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Validation error: {0}")]
Validation(String),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("Not found: {0}")]
NotFound(String),
}
impl From<ServerError> for ServerFnError {
fn from(err: ServerError) -> Self {
ServerFnError::new(err.to_string())
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ContentMetadata {
pub version: i32,
pub created_by: String,
pub last_modified_by: String,
pub workflow_status: WorkflowStatus,
pub tags: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum WorkflowStatus {
Draft,
InReview,
Published,
Archived,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PageContentVersion {
pub id: String,
pub content_id: String,
pub version: i32,
pub content: ContentBlock,
pub metadata: ContentMetadata,
pub created_at: DateTime<Utc>,
}
impl TryFrom<PgRow> for PageContentVersion {
type Error = ServerError;
fn try_from(row: PgRow) -> Result<Self, Self::Error> {
Ok(PageContentVersion {
id: row.get("id"),
content_id: row.get("content_id"),
version: row.get("version"),
content: serde_json::from_value(row.get("content"))?,
metadata: serde_json::from_value(row.get("metadata"))?,
created_at: row.get("created_at"),
})
}
}
const CREATE_TABLE_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS page_content_versions (
id TEXT PRIMARY KEY,
content_id TEXT NOT NULL,
version INTEGER NOT NULL,
content JSONB NOT NULL,
metadata JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
UNIQUE(content_id, version)
);
CREATE INDEX IF NOT EXISTS idx_content_search ON page_content_versions
USING gin (content jsonb_path_ops);
CREATE INDEX IF NOT EXISTS idx_metadata_search ON page_content_versions
USING gin (metadata jsonb_path_ops);
"#;
#[server(SaveContent)]
pub async fn save_content(
content: ContentBlock,
status: WorkflowStatus,
tags: Vec<String>,
) -> Result<PageContentVersion, ServerFnError> {
let pool = use_context::<PgPool>()
.expect("database connection not found");
let current_user = get_current_user()?;
validate_content(&content).map_err(ServerError::Validation)?;
let content_id = Uuid::new_v4().to_string();
let version_id = Uuid::new_v4().to_string();
let now = Utc::now();
let metadata = ContentMetadata {
version: 1,
created_by: current_user.clone(),
last_modified_by: current_user,
workflow_status: status,
tags,
};
let content_json = serde_json::to_value(&content)?;
let metadata_json = serde_json::to_value(&metadata)?;
let row = sqlx::query!(
r#"
INSERT INTO page_content_versions
(id, content_id, version, content, metadata, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
"#,
version_id,
content_id,
1,
content_json as _,
metadata_json as _,
now,
)
.fetch_one(&pool)
.await
.map_err(ServerError::Database)?;
Ok(PageContentVersion::try_from(row)?)
}
#[server(UpdateContent)]
pub async fn update_content(
content_id: String,
content: ContentBlock,
status: Option<WorkflowStatus>,
tags: Option<Vec<String>>,
) -> Result<PageContentVersion, ServerFnError> {
let pool = use_context::<PgPool>()
.expect("database connection not found");
let current_user = get_current_user()?;
validate_content(&content).map_err(ServerError::Validation)?;
// Get latest version
let latest = sqlx::query!(
r#"
SELECT metadata FROM page_content_versions
WHERE content_id = $1
ORDER BY version DESC
LIMIT 1
"#,
content_id
)
.fetch_optional(&pool)
.await
.map_err(ServerError::Database)?
.ok_or_else(|| ServerError::NotFound(format!("Content ID {} not found", content_id)))?;
let mut metadata: ContentMetadata = serde_json::from_value(latest.metadata)?;
metadata.version += 1;
metadata.last_modified_by = current_user;
if let Some(status) = status {
metadata.workflow_status = status;
}
if let Some(tags) = tags {
metadata.tags = tags;
}
let version_id = Uuid::new_v4().to_string();
let now = Utc::now();
let content_json = serde_json::to_value(&content)?;
let metadata_json = serde_json::to_value(&metadata)?;
let row = sqlx::query!(
r#"
INSERT INTO page_content_versions
(id, content_id, version, content, metadata, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
"#,
version_id,
content_id,
metadata.version,
content_json as _,
metadata_json as _,
now,
)
.fetch_one(&pool)
.await
.map_err(ServerError::Database)?;
Ok(PageContentVersion::try_from(row)?)
}
#[server(GetContent)]
pub async fn get_content(content_id: String) -> Result<PageContentVersion, ServerFnError> {
let pool = use_context::<PgPool>()
.expect("database connection not found");
let row = sqlx::query!(
r#"
SELECT * FROM page_content_versions
WHERE content_id = $1
ORDER BY version DESC
LIMIT 1
"#,
content_id
)
.fetch_optional(&pool)
.await
.map_err(ServerError::Database)?
.ok_or_else(|| ServerError::NotFound(format!("Content ID {} not found", content_id)))?;
Ok(PageContentVersion::try_from(row)?)
}
fn validate_content(content: &ContentBlock) -> Result<(), String> {
match content {
ContentBlock::Hero(hero) => {
if hero.title.len() > 100 {
return Err("Title exceeds maximum length".to_string());
}
if hero.description.len() > 500 {
return Err("Description exceeds maximum length".to_string());
}
// Add more validation based on field types
}
}
Ok(())
}
// Database initialization function
pub async fn initialize_database(pool: &PgPool) -> Result<(), sqlx::Error> {
sqlx::query(CREATE_TABLE_SQL)
.execute(pool)
.await?;
Ok(())
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment