Last active
November 12, 2024 00:45
-
-
Save matthewharwood/59a4033dec68c1bf7b74dfccc52fb8af to your computer and use it in GitHub Desktop.
Example of content blocks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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