good progress on pretty editor

Orual 735d9d74 0b325539

+462 -17
+132 -5
crates/weaver-app/assets/styling/record-view.css
··· 169 169 display: flex; 170 170 flex-direction: column; 171 171 padding: 0rem 0 0rem 1rem; 172 - 173 172 padding-right: 1rem; 174 173 border-left: 2px solid var(--color-secondary); 175 174 border-bottom: 1px dashed var(--color-subtle); ··· 241 240 padding-bottom: 0.25rem; 242 241 border-left: 2px solid var(--color-primary); 243 242 border-bottom: 1px dashed var(--color-muted); 244 - margin-left: -1.51rem; 243 + margin-left: -1.55rem; 245 244 } 246 245 247 246 .section-content .section-label { ··· 251 250 font-weight: 600; 252 251 padding-left: 1rem; 253 252 padding-top: 0.5rem; 254 - margin-left: -0.01rem; 253 + margin-left: -0.02rem; 255 254 border-left: 2px solid var(--color-secondary); 256 255 } 257 256 ··· 265 264 .section-content .record-field { 266 265 border-left-color: var(--color-secondary); 267 266 opacity: 0.95; 267 + align-self: stretch; 268 + width: 100%; 268 269 } 269 270 270 271 .blob-image { ··· 462 463 margin: 0.25rem 0; 463 464 } 464 465 466 + /* Array Section Styling */ 467 + .section-header { 468 + display: flex; 469 + align-items: baseline; 470 + gap: 0.5rem; 471 + } 472 + 473 + .array-item .section-header { 474 + margin-left: -1.52rem; 475 + } 476 + 477 + .array-length { 478 + font-family: var(--font-mono); 479 + font-size: 0.8rem; 480 + color: var(--color-subtle); 481 + font-weight: normal; 482 + } 483 + 484 + .array-item { 485 + width: 100%; 486 + display: flex; 487 + flex-direction: column; 488 + } 489 + 465 490 /* Pretty Editor Input Fields */ 466 491 .record-field input[type="text"], 467 492 .record-field input[type="number"] { ··· 474 499 padding: 0.2rem 0 0.1rem 0; 475 500 margin-top: 0.1rem; 476 501 outline: none; 502 + min-width: 10ch; 503 + max-width: 100%; 504 + width: auto; 505 + flex: 1 1 auto; 506 + transition: border-color 0.2s; 507 + } 508 + 509 + /* Hide number input spinner arrows */ 510 + .record-field input[type="number"] { 511 + -moz-appearance: textfield; 512 + } 513 + 514 + .record-field input[type="number"]::-webkit-outer-spin-button, 515 + .record-field input[type="number"]::-webkit-inner-spin-button { 516 + -webkit-appearance: none; 517 + margin: 0; 518 + } 519 + 520 + .record-field textarea { 521 + font-family: var(--font-mono); 522 + font-size: 0.9rem; 523 + color: var(--color-text); 524 + background: transparent; 525 + border: none; 526 + border-bottom: 1px solid transparent; 527 + padding: 0.2rem 0 0.1rem 0; 528 + margin-top: 0.1rem; 529 + outline: none; 477 530 width: 100%; 531 + min-height: 1.5em; 478 532 transition: border-color 0.2s; 533 + overflow-y: hidden; 534 + } 535 + 536 + /* Style textarea resize handle - webkit (Chrome/Safari) */ 537 + .record-field textarea::-webkit-resizer { 538 + background: transparent; 539 + border: 2px solid var(--color-border); 540 + border-left: none; 541 + border-bottom: none; 542 + border-top: none; 543 + } 544 + 545 + /* Style textarea resize handle - Firefox */ 546 + .record-field textarea { 547 + scrollbar-color: var(--color-border) transparent; 548 + scrollbar-width: thin; 479 549 } 480 550 481 551 .record-field input[type="text"]:focus, 482 - .record-field input[type="number"]:focus { 552 + .record-field input[type="number"]:focus, 553 + .record-field textarea:focus { 483 554 border-bottom-color: var(--color-primary); 484 555 } 485 556 486 557 .record-field input[type="text"].invalid, 487 - .record-field input[type="number"].invalid { 558 + .record-field input[type="number"].invalid, 559 + .record-field textarea.invalid { 488 560 border-bottom-color: var(--color-error, #ff6b6b); 489 561 } 490 562 ··· 592 664 .field-remove-button:hover { 593 665 color: var(--color-error, #ff6b6b); 594 666 } 667 + 668 + /* Blob Field Styling */ 669 + .blob-field { 670 + /* Inherits from .record-field */ 671 + } 672 + 673 + .blob-fields { 674 + display: flex; 675 + flex-direction: column; 676 + gap: 0.5rem; 677 + padding-top: 0.5rem; 678 + width: 100%; 679 + } 680 + 681 + .blob-field-row { 682 + display: flex; 683 + align-items: baseline; 684 + gap: 0.5rem; 685 + width: 100%; 686 + } 687 + 688 + .blob-field-row label { 689 + font-family: var(--font-mono); 690 + font-size: 0.75rem; 691 + color: var(--color-subtle); 692 + min-width: 4rem; 693 + text-transform: uppercase; 694 + letter-spacing: 0.05em; 695 + flex-shrink: 0; 696 + } 697 + 698 + .blob-field-cid { 699 + flex-wrap: wrap; 700 + } 701 + 702 + .blob-field-cid input { 703 + flex: 1 1 auto; 704 + width: 100% !important; 705 + min-width: unset !important; 706 + max-width: unset !important; 707 + font-size: 0.75rem; 708 + } 709 + 710 + .blob-field-row .readonly { 711 + font-family: var(--font-mono); 712 + font-size: 0.85rem; 713 + color: var(--color-text); 714 + opacity: 0.7; 715 + } 716 + 717 + .blob-upload-note { 718 + font-size: 0.75rem; 719 + font-style: italic; 720 + padding-top: 0.25rem; 721 + }
+330 -12
crates/weaver-app/src/views/record.rs
··· 813 813 } 814 814 } 815 815 816 + /// Create default value for new array item by cloning structure of existing items 817 + fn create_array_item_default(arr: &jacquard::types::value::Array) -> Data<'static> { 818 + if let Some(existing) = arr.0.first() { 819 + clone_structure(existing) 820 + } else { 821 + // Empty array, default to null (user can change type) 822 + Data::Null 823 + } 824 + } 825 + 826 + /// Clone structure of Data, setting sensible defaults for leaf values 827 + fn clone_structure(data: &Data) -> Data<'static> { 828 + use jacquard::types::string::*; 829 + use jacquard::types::value::{Array, Object}; 830 + use jacquard::types::{LexiconStringType, blob::*}; 831 + use std::collections::BTreeMap; 832 + 833 + match data { 834 + Data::Object(obj) => { 835 + let mut new_obj = BTreeMap::new(); 836 + for (key, value) in obj.0.iter() { 837 + new_obj.insert(key.clone(), clone_structure(value)); 838 + } 839 + Data::Object(Object(new_obj)) 840 + } 841 + 842 + Data::Array(_) => Data::Array(Array(Vec::new())), 843 + 844 + Data::String(s) => match s.string_type() { 845 + LexiconStringType::Datetime => { 846 + // Sensible default: now 847 + Data::String(AtprotoStr::Datetime(Datetime::now())) 848 + } 849 + _ => { 850 + // Empty string, type inference will handle it 851 + Data::String(AtprotoStr::String("".into())) 852 + } 853 + }, 854 + 855 + Data::Integer(_) => Data::Integer(0), 856 + Data::Boolean(_) => Data::Boolean(false), 857 + 858 + Data::Blob(blob) => { 859 + // Placeholder blob 860 + Data::Blob( 861 + Blob { 862 + r#ref: CidLink::str( 863 + "bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 864 + ), 865 + mime_type: blob.mime_type.clone(), 866 + size: 0, 867 + } 868 + .into_static(), 869 + ) 870 + } 871 + 872 + Data::Bytes(_) | Data::CidLink(_) | Data::Null => Data::Null, 873 + } 874 + } 875 + 816 876 // ============================================================================ 817 877 // Pretty Editor: Component Hierarchy 818 878 // ============================================================================ ··· 832 892 .get_at_path(&path_for_memo) 833 893 .map(|d| d.clone().into_static()) 834 894 { 835 - Some(Data::Object(_)) => rsx! { EditableObjectField { root, path: path.clone(), did } }, 895 + Some(Data::Object(_)) => rsx! { EditableObjectField { root, path: path.clone(), did, remove_button } }, 896 + Some(Data::Array(_)) => rsx! { EditableArrayField { root, path: path.clone(), did } }, 836 897 Some(Data::String(_)) => { 837 898 rsx! { EditableStringField { root, path: path.clone(), remove_button } } 838 899 } ··· 843 904 rsx! { EditableBooleanField { root, path: path.clone(), remove_button } } 844 905 } 845 906 Some(Data::Null) => rsx! { EditableNullField { root, path: path.clone(), remove_button } }, 907 + Some(Data::Blob(_)) => rsx! { EditableBlobField { root, path: path.clone(), did, remove_button } }, 846 908 847 909 // Not yet implemented - fall back to read-only DataView 848 910 Some(data) => { ··· 917 979 }; 918 980 919 981 let type_label = format!("{:?}", string_type()).to_lowercase(); 982 + let is_plain_string = string_type() == LexiconStringType::String; 920 983 921 984 rsx! { 922 985 div { class: "record-field", ··· 927 990 } 928 991 {remove_button} 929 992 } 930 - input { 931 - r#type: "text", 932 - value: "{input_text}", 933 - oninput: handle_input, 934 - class: if parse_error().is_some() { "invalid" } else { "" }, 993 + if is_plain_string { 994 + textarea { 995 + value: "{input_text}", 996 + oninput: handle_input, 997 + class: if parse_error().is_some() { "invalid" } else { "" }, 998 + rows: "1", 999 + } 1000 + } else { 1001 + input { 1002 + r#type: "text", 1003 + value: "{input_text}", 1004 + oninput: handle_input, 1005 + class: if parse_error().is_some() { "invalid" } else { "" }, 1006 + } 935 1007 } 936 1008 if let Some(err) = parse_error() { 937 1009 span { class: "field-error", " ❌ {err}" } ··· 1086 1158 } 1087 1159 } 1088 1160 1161 + /// Blob field - shows CID, size (editable), mime type (read-only) 1162 + #[component] 1163 + fn EditableBlobField( 1164 + root: Signal<Data<'static>>, 1165 + path: String, 1166 + did: String, 1167 + #[props(default)] remove_button: Option<Element>, 1168 + ) -> Element { 1169 + let path_for_memo = path.clone(); 1170 + let blob_data = use_memo(move || { 1171 + root.read() 1172 + .get_at_path(&path_for_memo) 1173 + .and_then(|d| match d { 1174 + Data::Blob(blob) => Some(( 1175 + blob.r#ref.to_string(), 1176 + blob.size, 1177 + blob.mime_type.as_str().to_string(), 1178 + )), 1179 + _ => None, 1180 + }) 1181 + }); 1182 + 1183 + let mut cid_input = use_signal(|| String::new()); 1184 + let mut size_input = use_signal(|| String::new()); 1185 + let mut cid_error = use_signal(|| None::<String>); 1186 + let mut size_error = use_signal(|| None::<String>); 1187 + 1188 + // Sync inputs when blob data changes 1189 + use_effect(move || { 1190 + if let Some((cid, size, _)) = blob_data() { 1191 + cid_input.set(cid); 1192 + size_input.set(size.to_string()); 1193 + } 1194 + }); 1195 + 1196 + let path_for_cid = path.clone(); 1197 + let handle_cid_change = move |evt: dioxus::prelude::Event<dioxus::prelude::FormData>| { 1198 + let text = evt.value(); 1199 + cid_input.set(text.clone()); 1200 + 1201 + match jacquard::types::cid::CidLink::new_owned(text.as_bytes()) { 1202 + Ok(new_cid_link) => { 1203 + cid_error.set(None); 1204 + root.with_mut(|data| { 1205 + if let Some(Data::Blob(blob)) = data.get_at_path_mut(&path_for_cid) { 1206 + blob.r#ref = new_cid_link; 1207 + } 1208 + }); 1209 + } 1210 + Err(_) => { 1211 + cid_error.set(Some("Invalid CID format".to_string())); 1212 + } 1213 + } 1214 + }; 1215 + 1216 + let path_for_size = path.clone(); 1217 + let handle_size_change = move |evt: dioxus::prelude::Event<dioxus::prelude::FormData>| { 1218 + let text = evt.value(); 1219 + size_input.set(text.clone()); 1220 + 1221 + match text.parse::<usize>() { 1222 + Ok(new_size) => { 1223 + size_error.set(None); 1224 + root.with_mut(|data| { 1225 + if let Some(Data::Blob(blob)) = data.get_at_path_mut(&path_for_size) { 1226 + blob.size = new_size; 1227 + } 1228 + }); 1229 + } 1230 + Err(_) => { 1231 + size_error.set(Some("Must be a non-negative integer".to_string())); 1232 + } 1233 + } 1234 + }; 1235 + 1236 + let placeholder_cid = "bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; 1237 + let is_placeholder = blob_data() 1238 + .map(|(cid, _, _)| cid == placeholder_cid) 1239 + .unwrap_or(true); 1240 + let is_image = blob_data() 1241 + .map(|(_, _, mime)| mime.starts_with("image/")) 1242 + .unwrap_or(false); 1243 + 1244 + let image_url = if !is_placeholder && is_image { 1245 + blob_data().map(|(cid, _, mime)| { 1246 + let format = mime.strip_prefix("image/").unwrap_or("jpeg"); 1247 + format!( 1248 + "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}", 1249 + did, 1250 + cid, 1251 + format 1252 + ) 1253 + }) 1254 + } else { 1255 + None 1256 + }; 1257 + 1258 + rsx! { 1259 + div { class: "record-field blob-field", 1260 + div { class: "field-header", 1261 + PathLabel { path: path.clone() } 1262 + span { class: "string-type-tag", " [blob]" } 1263 + {remove_button} 1264 + } 1265 + div { class: "blob-fields", 1266 + div { class: "blob-field-row blob-field-cid", 1267 + label { "CID:" } 1268 + input { 1269 + r#type: "text", 1270 + value: "{cid_input}", 1271 + oninput: handle_cid_change, 1272 + class: if cid_error().is_some() { "invalid" } else { "" }, 1273 + } 1274 + if let Some(err) = cid_error() { 1275 + span { class: "field-error", " ❌ {err}" } 1276 + } 1277 + } 1278 + div { class: "blob-field-row", 1279 + label { "Size:" } 1280 + input { 1281 + r#type: "number", 1282 + value: "{size_input}", 1283 + oninput: handle_size_change, 1284 + class: if size_error().is_some() { "invalid" } else { "" }, 1285 + } 1286 + if let Some(err) = size_error() { 1287 + span { class: "field-error", " ❌ {err}" } 1288 + } 1289 + } 1290 + div { class: "blob-field-row", 1291 + label { "MIME Type:" } 1292 + span { class: "readonly", 1293 + "{blob_data().map(|(_, _, mime)| mime).unwrap_or_default()}" 1294 + } 1295 + } 1296 + if let Some(url) = image_url { 1297 + img { 1298 + src: "{url}", 1299 + alt: "Blob preview", 1300 + class: "blob-image", 1301 + } 1302 + } 1303 + if is_placeholder { 1304 + div { class: "muted blob-upload-note", 1305 + "File upload coming soon" 1306 + } 1307 + } 1308 + } 1309 + } 1310 + } 1311 + } 1312 + 1089 1313 // ============================================================================ 1090 1314 // Field with Remove Button Wrapper 1091 1315 // ============================================================================ ··· 1129 1353 } 1130 1354 1131 1355 // ============================================================================ 1356 + // Array Field Editor (enables recursion) 1357 + // ============================================================================ 1358 + 1359 + /// Array field - iterates items and renders child EditableDataView for each 1360 + #[component] 1361 + fn EditableArrayField(root: Signal<Data<'static>>, path: String, did: String) -> Element { 1362 + let path_for_memo = path.clone(); 1363 + let array_len = use_memo(move || { 1364 + root.read() 1365 + .get_at_path(&path_for_memo) 1366 + .and_then(|d| d.as_array()) 1367 + .map(|arr| arr.0.len()) 1368 + .unwrap_or(0) 1369 + }); 1370 + 1371 + let path_for_add = path.clone(); 1372 + 1373 + rsx! { 1374 + div { class: "record-section array-section", 1375 + div { class: "section-header", 1376 + div { class: "section-label", 1377 + { 1378 + let parts: Vec<&str> = path.split('.').collect(); 1379 + let final_part = parts.last().unwrap_or(&""); 1380 + rsx! { "{final_part}" } 1381 + } 1382 + } 1383 + span { class: "array-length", "[{array_len}]" } 1384 + } 1385 + 1386 + div { class: "section-content", 1387 + for idx in 0..array_len() { 1388 + { 1389 + let item_path = format!("{}[{}]", path, idx); 1390 + let path_for_remove = path.clone(); 1391 + 1392 + rsx! { 1393 + div { 1394 + class: "array-item", 1395 + key: "{item_path}", 1396 + 1397 + EditableDataView { 1398 + root: root, 1399 + path: item_path.clone(), 1400 + did: did.clone(), 1401 + remove_button: rsx! { 1402 + button { 1403 + class: "field-remove-button", 1404 + onclick: move |_| { 1405 + root.with_mut(|data| { 1406 + if let Some(Data::Array(arr)) = data.get_at_path_mut(&path_for_remove) { 1407 + arr.0.remove(idx); 1408 + } 1409 + }); 1410 + }, 1411 + "Remove" 1412 + } 1413 + } 1414 + } 1415 + } 1416 + } 1417 + } 1418 + } 1419 + div { 1420 + class: "add-field-widget", 1421 + button { 1422 + 1423 + onclick: move |_| { 1424 + root.with_mut(|data| { 1425 + if let Some(Data::Array(arr)) = data.get_at_path_mut(&path_for_add) { 1426 + let new_item = create_array_item_default(arr); 1427 + arr.0.push(new_item); 1428 + } 1429 + }); 1430 + }, 1431 + "+ Add Item" 1432 + } 1433 + } 1434 + 1435 + 1436 + } 1437 + } 1438 + } 1439 + } 1440 + 1441 + // ============================================================================ 1132 1442 // Object Field Editor (enables recursion) 1133 1443 // ============================================================================ 1134 1444 1135 1445 /// Object field - iterates fields and renders child EditableDataView for each 1136 1446 #[component] 1137 - fn EditableObjectField(root: Signal<Data<'static>>, path: String, did: String) -> Element { 1447 + fn EditableObjectField( 1448 + root: Signal<Data<'static>>, 1449 + path: String, 1450 + did: String, 1451 + #[props(default)] remove_button: Option<Element>, 1452 + ) -> Element { 1138 1453 let path_for_memo = path.clone(); 1139 1454 let field_keys = use_memo(move || { 1140 1455 root.read() ··· 1149 1464 rsx! { 1150 1465 if !is_root { 1151 1466 div { class: "record-section object-section", 1152 - div { class: "section-label", 1153 - { 1154 - let parts: Vec<&str> = path.split('.').collect(); 1155 - let final_part = parts.last().unwrap_or(&""); 1156 - rsx! { "{final_part}" } 1467 + div { class: "section-header", 1468 + div { class: "section-label", 1469 + { 1470 + let parts: Vec<&str> = path.split('.').collect(); 1471 + let final_part = parts.last().unwrap_or(&""); 1472 + rsx! { "{final_part}" } 1473 + } 1157 1474 } 1475 + {remove_button} 1158 1476 } 1159 1477 div { class: "section-content", 1160 1478 for key in field_keys() {