Sfoglia il codice sorgente

fix: view settings persistence, document PATCH, full-page document form

- Add quick_edit_mode, quick_edit_fields, show_edit_button to view HTTP handlers
- Add PATCH route for document updates (frontend uses PATCH, not PUT)
- Create DocumentForm page for full-page create/edit mode
- Add routes /views/:viewId/create and /views/:viewId/edit/:docId
- Fix Users page to always fetch from /users (not workspace members)
- Handle 401 responses in API client with redirect to login
- Add ConfirmModal component for delete confirmations
Fszontagh 6 mesi fa
parent
commit
be85a6c420

+ 11 - 1
webserver/include/smartbotic/webserver/view_service.hpp

@@ -15,10 +15,14 @@ enum class FieldType {
     Text,
     Number,
     Date,
+    DateTime,
     Boolean,
     Select,
     Reference,
-    Computed
+    Computed,
+    Email,
+    Url,
+    Color
 };
 
 /// Convert field type to/from string
@@ -55,9 +59,15 @@ struct ViewSchema {
 struct ViewSettings {
     bool is_default = false;
     bool show_in_sidebar = true;
+    bool show_create_button = true;   // Show create button in views
+    bool show_edit_button = true;     // Show edit button for documents
     std::string icon;
     nlohmann::json filters;  // Default filters
     nlohmann::json sort;     // Default sort
+    std::vector<std::string> quick_create_fields;  // Fields shown in quick create form (empty = all fields)
+    std::vector<std::string> quick_edit_fields;    // Fields shown in quick edit form (empty = all fields)
+    std::string quick_create_mode = "modal";  // "modal", "inline", or "page"
+    std::string quick_edit_mode = "modal";    // "modal", "inline", or "page"
 };
 
 /// View information

+ 116 - 1
webserver/src/auth_service.cpp

@@ -53,6 +53,31 @@ auto HexToBytes(const std::string& hex) -> std::vector<unsigned char> {
     return bytes;
 }
 
+// Base64 decoding for Argon2 (uses URL-safe base64 without padding, as per PHC string format)
+auto Base64Decode(const std::string& input) -> std::vector<unsigned char> {
+    // Argon2 PHC format uses standard base64 (not URL-safe) without padding
+    static const std::string base64_chars =
+        "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+
+    std::vector<unsigned char> result;
+    result.reserve((input.size() * 3) / 4);
+
+    int val = 0;
+    int valb = -8;
+    for (unsigned char c : input) {
+        if (c == '=') break;  // Padding (though Argon2 PHC format typically omits it)
+        auto pos = base64_chars.find(c);
+        if (pos == std::string::npos) continue;  // Skip invalid chars
+        val = (val << 6) + static_cast<int>(pos);
+        valb += 6;
+        if (valb >= 0) {
+            result.push_back(static_cast<unsigned char>((val >> valb) & 0xFF));
+            valb -= 8;
+        }
+    }
+    return result;
+}
+
 // Password hashing constants (using scrypt for OpenSSL 3.x compatibility)
 constexpr size_t kSaltLength = 16;
 constexpr size_t kHashLength = 32;
@@ -395,8 +420,98 @@ auto AuthService::VerifyPassword(const std::string& password, const std::string&
         return CRYPTO_memcmp(computed_hash.data(), expected_hash.data(), expected_hash.size()) == 0;
     }
 
+    // Argon2 format (from libsodium): $argon2id$v=19$m=<memory>,t=<time>,p=<parallelism>$<salt_base64>$<hash_base64>
+    // Also supports $argon2i$ and $argon2d$
+    if (stored_hash.substr(0, 8) == "$argon2i" || stored_hash.substr(0, 9) == "$argon2id" || stored_hash.substr(0, 8) == "$argon2d") {
+        // Determine algorithm variant
+        std::string algo;
+        size_t algo_end = stored_hash.find('$', 1);
+        if (algo_end == std::string::npos) return false;
+        algo = stored_hash.substr(1, algo_end - 1);
+
+        // Map to OpenSSL KDF name
+        std::string kdf_name;
+        if (algo == "argon2id") kdf_name = "ARGON2ID";
+        else if (algo == "argon2i") kdf_name = "ARGON2I";
+        else if (algo == "argon2d") kdf_name = "ARGON2D";
+        else return false;
+
+        // Parse version: $argon2id$v=19$...
+        size_t version_start = algo_end + 1;
+        size_t version_end = stored_hash.find('$', version_start);
+        if (version_end == std::string::npos) return false;
+        // Skip version for now (we handle v=19)
+
+        // Parse params: m=65536,t=2,p=1
+        size_t params_start = version_end + 1;
+        size_t params_end = stored_hash.find('$', params_start);
+        if (params_end == std::string::npos) return false;
+
+        std::string params_str = stored_hash.substr(params_start, params_end - params_start);
+        uint32_t memory = 65536;  // m in KB
+        uint32_t time_cost = 2;   // t (iterations)
+        uint32_t parallelism = 1; // p (lanes)
+
+        if (sscanf(params_str.c_str(), "m=%u,t=%u,p=%u", &memory, &time_cost, &parallelism) != 3) {
+            spdlog::warn("Failed to parse Argon2 parameters: {}", params_str);
+            return false;
+        }
+
+        // Parse salt (base64)
+        size_t salt_start = params_end + 1;
+        size_t salt_end = stored_hash.find('$', salt_start);
+        if (salt_end == std::string::npos) return false;
+
+        std::string salt_b64 = stored_hash.substr(salt_start, salt_end - salt_start);
+        auto salt = Base64Decode(salt_b64);
+
+        // Parse hash (base64)
+        std::string hash_b64 = stored_hash.substr(salt_end + 1);
+        auto expected_hash = Base64Decode(hash_b64);
+
+        if (salt.empty() || expected_hash.empty()) {
+            spdlog::warn("Failed to decode Argon2 salt or hash");
+            return false;
+        }
+
+        // Derive hash using OpenSSL Argon2
+        std::vector<unsigned char> computed_hash(expected_hash.size());
+
+        EVP_KDF* kdf = EVP_KDF_fetch(nullptr, kdf_name.c_str(), nullptr);
+        if (kdf == nullptr) {
+            spdlog::warn("Failed to fetch {} KDF", kdf_name);
+            return false;
+        }
+
+        EVP_KDF_CTX* kctx = EVP_KDF_CTX_new(kdf);
+        EVP_KDF_free(kdf);
+        if (kctx == nullptr) return false;
+
+        // OpenSSL Argon2 parameters
+        OSSL_PARAM params[7];
+        params[0] = OSSL_PARAM_construct_octet_string("pass",
+                        const_cast<char*>(password.data()), password.size());
+        params[1] = OSSL_PARAM_construct_octet_string("salt", salt.data(), salt.size());
+        params[2] = OSSL_PARAM_construct_uint32("lanes", &parallelism);
+        params[3] = OSSL_PARAM_construct_uint32("threads", &parallelism);
+        params[4] = OSSL_PARAM_construct_uint32("memcost", &memory);
+        params[5] = OSSL_PARAM_construct_uint32("iter", &time_cost);
+        params[6] = OSSL_PARAM_construct_end();
+
+        int result = EVP_KDF_derive(kctx, computed_hash.data(), computed_hash.size(), params);
+        EVP_KDF_CTX_free(kctx);
+
+        if (result != 1) {
+            spdlog::warn("Argon2 KDF derivation failed");
+            return false;
+        }
+
+        // Constant-time comparison
+        return CRYPTO_memcmp(computed_hash.data(), expected_hash.data(), expected_hash.size()) == 0;
+    }
+
     // Unknown format
-    spdlog::warn("Unknown password hash format");
+    spdlog::warn("Unknown password hash format: {}", stored_hash.substr(0, 20));
     return false;
 }
 

+ 129 - 26
webserver/src/http_server.cpp

@@ -738,6 +738,18 @@ void HttpServer::HandleBootstrap(const httplib::Request& req, httplib::Response&
         return;
     }
 
+    // Get the superadmin group to assign to the first user
+    std::string superadmin_group_id;
+    if (groupService_) {
+        auto sa_group = groupService_->GetSuperadminGroup();
+        if (sa_group.success && sa_group.group) {
+            superadmin_group_id = sa_group.group->id;
+            spdlog::info("Found superadmin group: {}", superadmin_group_id);
+        } else {
+            spdlog::warn("Superadmin group not found - first user will not have admin privileges");
+        }
+    }
+
     // Create a default workspace
     if (workspaceService_) {
         CreateWorkspaceRequest ws_request;
@@ -745,12 +757,18 @@ void HttpServer::HandleBootstrap(const httplib::Request& req, httplib::Response&
         ws_request.settings["default"] = true;
         auto ws_result = workspaceService_->CreateWorkspace(ws_request);
 
-        // Add user to workspace
+        // Add user to workspace with superadmin group
         if (ws_result.success && ws_result.workspace && membershipService_) {
             AddMemberRequest member_request;
             member_request.workspace_id = ws_result.workspace->id;
             member_request.user_id = result.user->id;
-            member_request.group_ids = {};
+            // Assign the superadmin group to the first user
+            if (!superadmin_group_id.empty()) {
+                member_request.group_ids = {superadmin_group_id};
+                spdlog::info("Assigning superadmin group to bootstrap user");
+            } else {
+                member_request.group_ids = {};
+            }
             [[maybe_unused]] auto member_result = membershipService_->AddMember(member_request);
         }
     }
@@ -877,6 +895,8 @@ void HttpServer::HandleAuthLogin(const httplib::Request& req, httplib::Response&
         }
 
         // Collect user's groups from memberships
+        // Note: Superadmin status is determined by group membership, not by special logic
+        // The first user gets superadmin group assigned during bootstrap
         std::vector<std::string> groups;
         if (membershipService_) {
             auto memberships = membershipService_->ListUserMemberships(user.id);
@@ -889,20 +909,6 @@ void HttpServer::HandleAuthLogin(const httplib::Request& req, httplib::Response&
             }
         }
 
-        // First registered user is automatically superadmin
-        if (groupService_) {
-            auto superadmin_group = groupService_->GetSuperadminGroup();
-            if (superadmin_group.success && superadmin_group.group) {
-                auto all_users = userService_->ListUsers();
-                if (!all_users.users.empty() && all_users.users[0].id == user.id) {
-                    const auto& sa_id = superadmin_group.group->id;
-                    if (std::find(groups.begin(), groups.end(), sa_id) == groups.end()) {
-                        groups.push_back(sa_id);
-                    }
-                }
-            }
-        }
-
         auto tokens = authService_->GenerateTokens(user.id, email, workspace_ids, groups);
 
         nlohmann::json response = {
@@ -1105,6 +1111,16 @@ auto HttpServer::AuthenticateRequest(const httplib::Request& req) -> std::option
         return std::nullopt;
     }
 
+    // Verify the user still exists in the database
+    // This handles cases where the database was reset or user was deleted
+    if (userService_ && validation.user) {
+        auto user_result = userService_->GetUser(validation.user->user_id);
+        if (!user_result.success || !user_result.user) {
+            spdlog::debug("Token user no longer exists in database: {}", validation.user->user_id);
+            return std::nullopt;
+        }
+    }
+
     return validation.user;
 }
 
@@ -3427,6 +3443,11 @@ void HttpServer::SetupDocumentRoutes() {
         HandleUpdateDocument(req, res);
     });
 
+    // PATCH /api/workspaces/:wid/collections/:name/documents/:id - Update a document (partial)
+    httpServer_->Patch(R"(/api/workspaces/([a-zA-Z0-9\-]+)/collections/([a-zA-Z_][a-zA-Z0-9_]*)/documents/([a-zA-Z0-9\-]+))", [this](const httplib::Request& req, httplib::Response& res) {
+        HandleUpdateDocument(req, res);
+    });
+
     // DELETE /api/workspaces/:wid/collections/:name/documents/:id - Delete a document
     httpServer_->Delete(R"(/api/workspaces/([a-zA-Z0-9\-]+)/collections/([a-zA-Z_][a-zA-Z0-9_]*)/documents/([a-zA-Z0-9\-]+))", [this](const httplib::Request& req, httplib::Response& res) {
         HandleDeleteDocument(req, res);
@@ -4080,9 +4101,27 @@ void HttpServer::HandleCreateView(const httplib::Request& req, httplib::Response
             const auto& settings_json = body["settings"];
             request.settings.is_default = settings_json.value("is_default", false);
             request.settings.show_in_sidebar = settings_json.value("show_in_sidebar", true);
+            request.settings.show_create_button = settings_json.value("show_create_button", true);
             request.settings.icon = settings_json.value("icon", "");
             request.settings.filters = settings_json.value("filters", nlohmann::json::object());
             request.settings.sort = settings_json.value("sort", nlohmann::json::object());
+            request.settings.quick_create_mode = settings_json.value("quick_create_mode", "modal");
+            request.settings.quick_edit_mode = settings_json.value("quick_edit_mode", "modal");
+            request.settings.show_edit_button = settings_json.value("show_edit_button", true);
+            if (settings_json.contains("quick_create_fields") && settings_json["quick_create_fields"].is_array()) {
+                for (const auto& field : settings_json["quick_create_fields"]) {
+                    if (field.is_string()) {
+                        request.settings.quick_create_fields.push_back(field.get<std::string>());
+                    }
+                }
+            }
+            if (settings_json.contains("quick_edit_fields") && settings_json["quick_edit_fields"].is_array()) {
+                for (const auto& field : settings_json["quick_edit_fields"]) {
+                    if (field.is_string()) {
+                        request.settings.quick_edit_fields.push_back(field.get<std::string>());
+                    }
+                }
+            }
         }
 
         auto result = viewService_->CreateView(request);
@@ -4140,13 +4179,24 @@ void HttpServer::HandleCreateView(const httplib::Request& req, httplib::Response
         view_json["schema"] = schema_json;
 
         // Add settings
-        view_json["settings"] = {
+        nlohmann::json create_settings_json = {
             {"is_default", result.view->settings.is_default},
             {"show_in_sidebar", result.view->settings.show_in_sidebar},
+            {"show_create_button", result.view->settings.show_create_button},
+            {"show_edit_button", result.view->settings.show_edit_button},
             {"icon", result.view->settings.icon},
             {"filters", result.view->settings.filters},
-            {"sort", result.view->settings.sort}
+            {"sort", result.view->settings.sort},
+            {"quick_create_mode", result.view->settings.quick_create_mode},
+            {"quick_edit_mode", result.view->settings.quick_edit_mode}
         };
+        if (!result.view->settings.quick_create_fields.empty()) {
+            create_settings_json["quick_create_fields"] = result.view->settings.quick_create_fields;
+        }
+        if (!result.view->settings.quick_edit_fields.empty()) {
+            create_settings_json["quick_edit_fields"] = result.view->settings.quick_edit_fields;
+        }
+        view_json["settings"] = create_settings_json;
 
         res.status = 201;
         res.set_content(view_json.dump(), "application/json");
@@ -4232,11 +4282,22 @@ void HttpServer::HandleListViews(const httplib::Request& req, httplib::Response&
             };
 
             // Add settings
-            view_json["settings"] = {
+            nlohmann::json settings_json = {
                 {"is_default", view.settings.is_default},
                 {"show_in_sidebar", view.settings.show_in_sidebar},
-                {"icon", view.settings.icon}
+                {"show_create_button", view.settings.show_create_button},
+                {"show_edit_button", view.settings.show_edit_button},
+                {"icon", view.settings.icon},
+                {"quick_create_mode", view.settings.quick_create_mode},
+                {"quick_edit_mode", view.settings.quick_edit_mode}
             };
+            if (!view.settings.quick_create_fields.empty()) {
+                settings_json["quick_create_fields"] = view.settings.quick_create_fields;
+            }
+            if (!view.settings.quick_edit_fields.empty()) {
+                settings_json["quick_edit_fields"] = view.settings.quick_edit_fields;
+            }
+            view_json["settings"] = settings_json;
 
             views_json.push_back(view_json);
         }
@@ -4346,13 +4407,24 @@ void HttpServer::HandleGetView(const httplib::Request& req, httplib::Response& r
         view_json["schema"] = schema_json;
 
         // Add settings
-        view_json["settings"] = {
+        nlohmann::json settings_json = {
             {"is_default", result.view->settings.is_default},
             {"show_in_sidebar", result.view->settings.show_in_sidebar},
+            {"show_create_button", result.view->settings.show_create_button},
+            {"show_edit_button", result.view->settings.show_edit_button},
             {"icon", result.view->settings.icon},
             {"filters", result.view->settings.filters},
-            {"sort", result.view->settings.sort}
+            {"sort", result.view->settings.sort},
+            {"quick_create_mode", result.view->settings.quick_create_mode},
+            {"quick_edit_mode", result.view->settings.quick_edit_mode}
         };
+        if (!result.view->settings.quick_create_fields.empty()) {
+            settings_json["quick_create_fields"] = result.view->settings.quick_create_fields;
+        }
+        if (!result.view->settings.quick_edit_fields.empty()) {
+            settings_json["quick_edit_fields"] = result.view->settings.quick_edit_fields;
+        }
+        view_json["settings"] = settings_json;
 
         res.set_content(view_json.dump(), "application/json");
 
@@ -4460,9 +4532,27 @@ void HttpServer::HandleUpdateView(const httplib::Request& req, httplib::Response
             const auto& settings_json = body["settings"];
             settings.is_default = settings_json.value("is_default", false);
             settings.show_in_sidebar = settings_json.value("show_in_sidebar", true);
+            settings.show_create_button = settings_json.value("show_create_button", true);
             settings.icon = settings_json.value("icon", "");
             settings.filters = settings_json.value("filters", nlohmann::json::object());
             settings.sort = settings_json.value("sort", nlohmann::json::object());
+            settings.quick_create_mode = settings_json.value("quick_create_mode", "modal");
+            settings.quick_edit_mode = settings_json.value("quick_edit_mode", "modal");
+            settings.show_edit_button = settings_json.value("show_edit_button", true);
+            if (settings_json.contains("quick_create_fields") && settings_json["quick_create_fields"].is_array()) {
+                for (const auto& field : settings_json["quick_create_fields"]) {
+                    if (field.is_string()) {
+                        settings.quick_create_fields.push_back(field.get<std::string>());
+                    }
+                }
+            }
+            if (settings_json.contains("quick_edit_fields") && settings_json["quick_edit_fields"].is_array()) {
+                for (const auto& field : settings_json["quick_edit_fields"]) {
+                    if (field.is_string()) {
+                        settings.quick_edit_fields.push_back(field.get<std::string>());
+                    }
+                }
+            }
             request.settings = settings;
         }
 
@@ -4521,13 +4611,24 @@ void HttpServer::HandleUpdateView(const httplib::Request& req, httplib::Response
         view_json["schema"] = schema_json;
 
         // Add settings
-        view_json["settings"] = {
+        nlohmann::json settings_json = {
             {"is_default", result.view->settings.is_default},
             {"show_in_sidebar", result.view->settings.show_in_sidebar},
+            {"show_create_button", result.view->settings.show_create_button},
+            {"show_edit_button", result.view->settings.show_edit_button},
             {"icon", result.view->settings.icon},
             {"filters", result.view->settings.filters},
-            {"sort", result.view->settings.sort}
+            {"sort", result.view->settings.sort},
+            {"quick_create_mode", result.view->settings.quick_create_mode},
+            {"quick_edit_mode", result.view->settings.quick_edit_mode}
         };
+        if (!result.view->settings.quick_create_fields.empty()) {
+            settings_json["quick_create_fields"] = result.view->settings.quick_create_fields;
+        }
+        if (!result.view->settings.quick_edit_fields.empty()) {
+            settings_json["quick_edit_fields"] = result.view->settings.quick_edit_fields;
+        }
+        view_json["settings"] = settings_json;
 
         res.set_content(view_json.dump(), "application/json");
 
@@ -4869,7 +4970,8 @@ void HttpServer::HandleListPages(const httplib::Request& req, httplib::Response&
             pages_json.push_back(page_json);
         }
 
-        res.set_content(pages_json.dump(), "application/json");
+        nlohmann::json response = {{"pages", pages_json}};
+        res.set_content(response.dump(), "application/json");
 
     } catch (const std::exception& e) {
         spdlog::error("List pages error: {}", e.what());
@@ -4939,7 +5041,8 @@ void HttpServer::HandleListSidebarPages(const httplib::Request& req, httplib::Re
             pages_json.push_back(page_json);
         }
 
-        res.set_content(pages_json.dump(), "application/json");
+        nlohmann::json response = {{"pages", pages_json}};
+        res.set_content(response.dump(), "application/json");
 
     } catch (const std::exception& e) {
         spdlog::error("List sidebar pages error: {}", e.what());

+ 39 - 2
webserver/src/view_service.cpp

@@ -13,10 +13,14 @@ auto FieldTypeToString(FieldType type) -> std::string {
         case FieldType::Text: return "text";
         case FieldType::Number: return "number";
         case FieldType::Date: return "date";
+        case FieldType::DateTime: return "datetime";
         case FieldType::Boolean: return "boolean";
         case FieldType::Select: return "select";
         case FieldType::Reference: return "reference";
         case FieldType::Computed: return "computed";
+        case FieldType::Email: return "email";
+        case FieldType::Url: return "url";
+        case FieldType::Color: return "color";
     }
     return "text";
 }
@@ -24,10 +28,14 @@ auto FieldTypeToString(FieldType type) -> std::string {
 auto StringToFieldType(const std::string& str) -> FieldType {
     if (str == "number") return FieldType::Number;
     if (str == "date") return FieldType::Date;
+    if (str == "datetime") return FieldType::DateTime;
     if (str == "boolean") return FieldType::Boolean;
     if (str == "select") return FieldType::Select;
     if (str == "reference") return FieldType::Reference;
     if (str == "computed") return FieldType::Computed;
+    if (str == "email") return FieldType::Email;
+    if (str == "url") return FieldType::Url;
+    if (str == "color") return FieldType::Color;
     return FieldType::Text;
 }
 
@@ -550,22 +558,51 @@ auto ViewService::JsonToSchema(const nlohmann::json& json) -> ViewSchema {
 }
 
 auto ViewService::SettingsToJson(const ViewSettings& settings) -> nlohmann::json {
-    return {
+    nlohmann::json json = {
         {"is_default", settings.is_default},
         {"show_in_sidebar", settings.show_in_sidebar},
+        {"show_create_button", settings.show_create_button},
+        {"show_edit_button", settings.show_edit_button},
         {"icon", settings.icon},
         {"filters", settings.filters},
-        {"sort", settings.sort}
+        {"sort", settings.sort},
+        {"quick_create_mode", settings.quick_create_mode},
+        {"quick_edit_mode", settings.quick_edit_mode}
     };
+    if (!settings.quick_create_fields.empty()) {
+        json["quick_create_fields"] = settings.quick_create_fields;
+    }
+    if (!settings.quick_edit_fields.empty()) {
+        json["quick_edit_fields"] = settings.quick_edit_fields;
+    }
+    return json;
 }
 
 auto ViewService::JsonToSettings(const nlohmann::json& json) -> ViewSettings {
     ViewSettings settings;
     settings.is_default = json.value("is_default", false);
     settings.show_in_sidebar = json.value("show_in_sidebar", true);
+    settings.show_create_button = json.value("show_create_button", true);
+    settings.show_edit_button = json.value("show_edit_button", true);
     settings.icon = json.value("icon", "");
     settings.filters = json.value("filters", nlohmann::json::object());
     settings.sort = json.value("sort", nlohmann::json::object());
+    settings.quick_create_mode = json.value("quick_create_mode", "modal");
+    settings.quick_edit_mode = json.value("quick_edit_mode", "modal");
+    if (json.contains("quick_create_fields") && json["quick_create_fields"].is_array()) {
+        for (const auto& field : json["quick_create_fields"]) {
+            if (field.is_string()) {
+                settings.quick_create_fields.push_back(field.get<std::string>());
+            }
+        }
+    }
+    if (json.contains("quick_edit_fields") && json["quick_edit_fields"].is_array()) {
+        for (const auto& field : json["quick_edit_fields"]) {
+            if (field.is_string()) {
+                settings.quick_edit_fields.push_back(field.get<std::string>());
+            }
+        }
+    }
     return settings;
 }
 

+ 3 - 0
webui/src/App.tsx

@@ -8,6 +8,7 @@ import Views from '@/pages/Views'
 import Pages from '@/pages/Pages'
 import PageDisplay from '@/pages/PageDisplay'
 import PageBuilder from '@/components/PageBuilder'
+import DocumentForm from '@/pages/DocumentForm'
 import Users from '@/pages/Users'
 import Groups from '@/pages/Groups'
 import ApiKeys from '@/pages/ApiKeys'
@@ -62,6 +63,8 @@ function App() {
         <Route path="/collections/:collectionName" element={<Documents />} />
         <Route path="/views" element={<Views />} />
         <Route path="/views/:collectionName" element={<Views />} />
+        <Route path="/views/:viewId/create" element={<DocumentForm />} />
+        <Route path="/views/:viewId/edit/:docId" element={<DocumentForm />} />
         <Route path="/pages" element={<Pages />} />
         <Route path="/pages/:pageId/edit" element={<PageBuilder />} />
         <Route path="/p/:slug" element={<PageDisplay />} />

+ 16 - 0
webui/src/api/client.ts

@@ -37,6 +37,22 @@ class ApiClient {
       body: body ? JSON.stringify(body) : undefined,
     })
 
+    // Handle 401 Unauthorized - user session is invalid
+    // This happens when: token expired, user deleted, database reset, etc.
+    if (response.status === 401) {
+      // Don't redirect if we're already on auth endpoints
+      const isAuthEndpoint = endpoint.startsWith('/auth/')
+      if (!isAuthEndpoint) {
+        // Clear stored tokens
+        this.accessToken = null
+        localStorage.removeItem('access_token')
+        localStorage.removeItem('refresh_token')
+        // Redirect to login
+        window.location.href = '/login'
+        throw new Error('Session expired')
+      }
+    }
+
     if (!response.ok) {
       const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
       throw new Error(errorData.error || `HTTP ${response.status}`)

+ 129 - 0
webui/src/components/ConfirmModal.tsx

@@ -0,0 +1,129 @@
+// Confirmation modal component - replaces browser confirm()
+
+import { useEffect, useRef } from 'react'
+import Button from './Button'
+
+interface ConfirmModalProps {
+  isOpen: boolean
+  title: string
+  message: string
+  confirmLabel?: string
+  cancelLabel?: string
+  variant?: 'danger' | 'warning' | 'info'
+  isLoading?: boolean
+  onConfirm: () => void
+  onCancel: () => void
+}
+
+export function ConfirmModal({
+  isOpen,
+  title,
+  message,
+  confirmLabel = 'Confirm',
+  cancelLabel = 'Cancel',
+  variant = 'danger',
+  isLoading = false,
+  onConfirm,
+  onCancel,
+}: ConfirmModalProps) {
+  const confirmButtonRef = useRef<HTMLButtonElement>(null)
+
+  // Focus confirm button when modal opens
+  useEffect(() => {
+    if (isOpen && confirmButtonRef.current) {
+      confirmButtonRef.current.focus()
+    }
+  }, [isOpen])
+
+  // Handle escape key
+  useEffect(() => {
+    const handleEscape = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && isOpen && !isLoading) {
+        onCancel()
+      }
+    }
+    document.addEventListener('keydown', handleEscape)
+    return () => document.removeEventListener('keydown', handleEscape)
+  }, [isOpen, isLoading, onCancel])
+
+  if (!isOpen) return null
+
+  const variantStyles = {
+    danger: {
+      icon: (
+        <svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
+        </svg>
+      ),
+      iconBg: 'bg-red-100',
+      buttonVariant: 'danger' as const,
+    },
+    warning: {
+      icon: (
+        <svg className="h-6 w-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
+        </svg>
+      ),
+      iconBg: 'bg-yellow-100',
+      buttonVariant: 'primary' as const,
+    },
+    info: {
+      icon: (
+        <svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+        </svg>
+      ),
+      iconBg: 'bg-blue-100',
+      buttonVariant: 'primary' as const,
+    },
+  }
+
+  const styles = variantStyles[variant]
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto">
+      {/* Backdrop */}
+      <div
+        className="fixed inset-0 bg-black/50 transition-opacity"
+        onClick={!isLoading ? onCancel : undefined}
+      />
+
+      {/* Modal */}
+      <div className="relative w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
+        <div className="flex items-start gap-4">
+          {/* Icon */}
+          <div className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full ${styles.iconBg}`}>
+            {styles.icon}
+          </div>
+
+          {/* Content */}
+          <div className="flex-1">
+            <h3 className="text-lg font-semibold text-gray-900">{title}</h3>
+            <p className="mt-2 text-sm text-gray-600">{message}</p>
+          </div>
+        </div>
+
+        {/* Actions */}
+        <div className="mt-6 flex justify-end gap-3">
+          <Button
+            variant="secondary"
+            onClick={onCancel}
+            disabled={isLoading}
+          >
+            {cancelLabel}
+          </Button>
+          <Button
+            ref={confirmButtonRef}
+            variant={styles.buttonVariant}
+            onClick={onConfirm}
+            isLoading={isLoading}
+          >
+            {confirmLabel}
+          </Button>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export default ConfirmModal

+ 17 - 0
webui/src/components/PageBuilder/index.tsx

@@ -130,6 +130,21 @@ export function PageBuilder() {
     setSelectedComponent(null)
   }, [page, selectedComponent])
 
+  // Reorder components (drag & drop)
+  const handleReorderComponents = useCallback((newComponents: LayoutComponent[]) => {
+    if (!page) return
+
+    setPage({
+      ...page,
+      layout: {
+        ...page.layout,
+        version: page.layout?.version || 1,
+        grid_columns: page.layout?.grid_columns || 12,
+        components: newComponents,
+      },
+    })
+  }, [page])
+
   // Save page
   const handleSave = useCallback(async () => {
     if (!page || !currentWorkspace) return
@@ -277,6 +292,8 @@ export function PageBuilder() {
               page={page}
               isEditing={true}
               onComponentClick={setSelectedComponent}
+              onReorder={handleReorderComponents}
+              selectedComponentId={selectedComponent?.id || null}
             />
           </div>
         </div>

+ 699 - 221
webui/src/components/PageRenderer/DocumentListRenderer.tsx

@@ -2,22 +2,36 @@
 // Displays documents from a view in table, card, or kanban mode
 
 import { useState, useEffect, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
 import apiClient from '@/api/client'
 import { useWorkspace } from '@/contexts/WorkspaceContext'
 import { usePermissions } from '@/hooks/usePermissions'
 import Button from '@/components/Button'
+import ConfirmModal from '@/components/ConfirmModal'
 import type { DocumentListConfig, Document } from '@/types'
 
+interface ViewField {
+  name: string
+  label: string
+  type: string
+  required?: boolean
+  options?: Array<{ value: string; label: string }> | string[]
+}
+
 interface View {
   id: string
   name: string
   collection_name: string
   schema: {
-    fields?: Array<{
-      name: string
-      label: string
-      type: string
-    }>
+    fields?: ViewField[]
+  }
+  settings?: {
+    show_create_button?: boolean
+    show_edit_button?: boolean
+    quick_create_mode?: 'modal' | 'inline' | 'page'
+    quick_edit_mode?: 'modal' | 'inline' | 'page'
+    quick_create_fields?: string[]
+    quick_edit_fields?: string[]
   }
 }
 
@@ -28,12 +42,26 @@ interface DocumentListRendererProps {
   onDeleteDocument?: (doc: Document) => void
 }
 
+// Normalize options to {value, label} format
+function normalizeOptions(options: Array<{ value: string; label: string }> | string[] | undefined): Array<{ value: string; label: string }> {
+  if (!options) return []
+  return options.map((opt): { value: string; label: string } => {
+    if (typeof opt === 'string') {
+      return { value: opt, label: opt }
+    }
+    return { value: opt.value, label: opt.label }
+  })
+}
+
 export function DocumentListRenderer({
   config,
   onCreateDocument,
   onEditDocument,
-  onDeleteDocument,
+  // Note: delete is now handled internally with ConfirmModal
+  onDeleteDocument: _onDeleteDocument,
 }: DocumentListRendererProps) {
+  void _onDeleteDocument // Silence unused variable warning
+  const navigate = useNavigate()
   const { currentWorkspace } = useWorkspace()
   const workspaceId = currentWorkspace?.id || ''
   const { canCreateInCollection, canWriteCollection, canDeleteInCollection } = usePermissions({
@@ -47,6 +75,21 @@ export function DocumentListRenderer({
   const [page, setPage] = useState(1)
   const [hasMore, setHasMore] = useState(false)
 
+  // Inline create form state
+  const [inlineFormData, setInlineFormData] = useState<Record<string, unknown>>({})
+  const [isSubmitting, setIsSubmitting] = useState(false)
+  const [submitError, setSubmitError] = useState<string | null>(null)
+
+  // Inline edit state
+  const [editingDoc, setEditingDoc] = useState<Document | null>(null)
+  const [editFormData, setEditFormData] = useState<Record<string, unknown>>({})
+  const [isUpdating, setIsUpdating] = useState(false)
+  const [updateError, setUpdateError] = useState<string | null>(null)
+
+  // Delete confirmation modal state
+  const [deleteDoc, setDeleteDoc] = useState<Document | null>(null)
+  const [isDeleting, setIsDeleting] = useState(false)
+
   // Fetch view details
   const fetchView = useCallback(async () => {
     if (!workspaceId || !config.viewId) return null
@@ -102,6 +145,357 @@ export function DocumentListRenderer({
   const fields = view?.schema?.fields || []
   const collectionName = view?.collection_name || ''
 
+  // Get quick create/edit fields
+  const getQuickFields = (mode: 'create' | 'edit'): ViewField[] => {
+    const allFields = view?.schema?.fields || []
+    const quickFields = mode === 'create'
+      ? view?.settings?.quick_create_fields
+      : view?.settings?.quick_edit_fields
+
+    if (quickFields && quickFields.length > 0) {
+      return quickFields
+        .map(name => allFields.find(f => f.name === name))
+        .filter((f): f is ViewField => f !== undefined)
+    }
+
+    return allFields
+  }
+
+  // Handle inline create form submission
+  const handleInlineSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    if (!view || !workspaceId) return
+
+    setIsSubmitting(true)
+    setSubmitError(null)
+
+    try {
+      await apiClient.post(
+        `/workspaces/${workspaceId}/collections/${view.collection_name}/documents`,
+        { data: inlineFormData }
+      )
+      setInlineFormData({})
+      await fetchDocuments(view)
+    } catch (err) {
+      setSubmitError(err instanceof Error ? err.message : 'Failed to create document')
+    } finally {
+      setIsSubmitting(false)
+    }
+  }
+
+  // Start inline edit
+  const startInlineEdit = (doc: Document) => {
+    setEditingDoc(doc)
+    setEditFormData({ ...doc.data })
+    setUpdateError(null)
+  }
+
+  // Cancel inline edit
+  const cancelInlineEdit = () => {
+    setEditingDoc(null)
+    setEditFormData({})
+    setUpdateError(null)
+  }
+
+  // Handle inline edit form submission
+  const handleInlineEditSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    if (!view || !workspaceId || !editingDoc) return
+
+    setIsUpdating(true)
+    setUpdateError(null)
+
+    try {
+      await apiClient.patch(
+        `/workspaces/${workspaceId}/collections/${view.collection_name}/documents/${editingDoc.id}`,
+        { data: editFormData }
+      )
+      setEditingDoc(null)
+      setEditFormData({})
+      await fetchDocuments(view)
+    } catch (err) {
+      setUpdateError(err instanceof Error ? err.message : 'Failed to update document')
+    } finally {
+      setIsUpdating(false)
+    }
+  }
+
+  // Handle delete with confirmation modal
+  const handleDeleteClick = (doc: Document) => {
+    setDeleteDoc(doc)
+  }
+
+  const handleDeleteConfirm = async () => {
+    if (!view || !workspaceId || !deleteDoc) return
+
+    setIsDeleting(true)
+
+    try {
+      await apiClient.delete(
+        `/workspaces/${workspaceId}/collections/${view.collection_name}/documents/${deleteDoc.id}`
+      )
+      setDeleteDoc(null)
+      await fetchDocuments(view)
+    } catch (err) {
+      console.error('Failed to delete document:', err)
+    } finally {
+      setIsDeleting(false)
+    }
+  }
+
+  // Handle "page" mode - navigate to create/edit page
+  const handlePageCreate = () => {
+    if (view) {
+      navigate(`/views/${view.id}/create`)
+    }
+  }
+
+  const handlePageEdit = (doc: Document) => {
+    if (view) {
+      navigate(`/views/${view.id}/edit/${doc.id}`)
+    }
+  }
+
+  // Render form field (used for both create and edit)
+  const renderFormField = (field: ViewField, formData: Record<string, unknown>, setFormData: React.Dispatch<React.SetStateAction<Record<string, unknown>>>, compact = false) => {
+    const value = formData[field.name] ?? ''
+
+    if (field.type === 'select' && field.options) {
+      const opts = normalizeOptions(field.options)
+      return (
+        <div key={field.name} className={compact ? "flex-1 min-w-[120px]" : ""}>
+          {!compact && (
+            <label className="mb-1 block text-sm font-medium text-gray-700">
+              {field.label}
+              {field.required && <span className="text-red-500 ml-0.5">*</span>}
+            </label>
+          )}
+          <select
+            value={String(value)}
+            onChange={(e) => setFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
+            required={field.required}
+            title={compact ? field.label : undefined}
+          >
+            <option value="">{compact ? field.label : 'Select...'}</option>
+            {opts.map(opt => (
+              <option key={opt.value} value={opt.value}>{opt.label}</option>
+            ))}
+          </select>
+        </div>
+      )
+    }
+
+    if (field.type === 'boolean') {
+      return (
+        <div key={field.name} className={compact ? "flex items-center" : ""}>
+          <label className="flex items-center gap-2">
+            <input
+              type="checkbox"
+              checked={Boolean(value)}
+              onChange={(e) => setFormData(prev => ({ ...prev, [field.name]: e.target.checked }))}
+              className="h-4 w-4 rounded border-gray-300"
+            />
+            <span className="text-sm text-gray-700">{field.label}</span>
+          </label>
+        </div>
+      )
+    }
+
+    if (field.type === 'date') {
+      return (
+        <div key={field.name} className={compact ? "flex-1 min-w-[140px]" : ""}>
+          {!compact && (
+            <label className="mb-1 block text-sm font-medium text-gray-700">
+              {field.label}
+              {field.required && <span className="text-red-500 ml-0.5">*</span>}
+            </label>
+          )}
+          <input
+            type="date"
+            value={String(value)}
+            onChange={(e) => setFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
+            required={field.required}
+            title={compact ? field.label : undefined}
+          />
+        </div>
+      )
+    }
+
+    if (field.type === 'datetime') {
+      return (
+        <div key={field.name} className={compact ? "flex-1 min-w-[180px]" : ""}>
+          {!compact && (
+            <label className="mb-1 block text-sm font-medium text-gray-700">
+              {field.label}
+              {field.required && <span className="text-red-500 ml-0.5">*</span>}
+            </label>
+          )}
+          <input
+            type="datetime-local"
+            value={String(value)}
+            onChange={(e) => setFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
+            required={field.required}
+            title={compact ? field.label : undefined}
+          />
+        </div>
+      )
+    }
+
+    if (field.type === 'number') {
+      return (
+        <div key={field.name} className={compact ? "flex-1 min-w-[80px]" : ""}>
+          {!compact && (
+            <label className="mb-1 block text-sm font-medium text-gray-700">
+              {field.label}
+              {field.required && <span className="text-red-500 ml-0.5">*</span>}
+            </label>
+          )}
+          <input
+            type="number"
+            value={String(value)}
+            onChange={(e) => setFormData(prev => ({ ...prev, [field.name]: parseFloat(e.target.value) || 0 }))}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
+            placeholder={compact ? field.label : undefined}
+            required={field.required}
+          />
+        </div>
+      )
+    }
+
+    if (field.type === 'color') {
+      return (
+        <div key={field.name} className={compact ? "flex-shrink-0" : ""}>
+          {!compact && (
+            <label className="mb-1 block text-sm font-medium text-gray-700">
+              {field.label}
+              {field.required && <span className="text-red-500 ml-0.5">*</span>}
+            </label>
+          )}
+          <input
+            type="color"
+            value={String(value) || '#000000'}
+            onChange={(e) => setFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
+            className="h-9 w-14 cursor-pointer rounded-lg border border-gray-300 p-1"
+            title={compact ? field.label : undefined}
+            required={field.required}
+          />
+        </div>
+      )
+    }
+
+    // Default: text input
+    return (
+      <div key={field.name} className={compact ? "flex-1 min-w-[120px]" : ""}>
+        {!compact && (
+          <label className="mb-1 block text-sm font-medium text-gray-700">
+            {field.label}
+            {field.required && <span className="text-red-500 ml-0.5">*</span>}
+          </label>
+        )}
+        <input
+          type={field.type === 'email' ? 'email' : field.type === 'url' ? 'url' : 'text'}
+          value={String(value)}
+          onChange={(e) => setFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
+          className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
+          placeholder={compact ? field.label : undefined}
+          required={field.required}
+        />
+      </div>
+    )
+  }
+
+  // Determine modes
+  const quickCreateMode = view?.settings?.quick_create_mode || 'modal'
+  const quickEditMode = view?.settings?.quick_edit_mode || 'modal'
+  const showCreateButton = view?.settings?.show_create_button !== false && canCreateInCollection(collectionName)
+  const showEditButton = view?.settings?.show_edit_button !== false && canWriteCollection(collectionName)
+
+  // Handle edit based on mode
+  const handleEdit = (doc: Document) => {
+    if (quickEditMode === 'inline') {
+      startInlineEdit(doc)
+    } else if (quickEditMode === 'page') {
+      handlePageEdit(doc)
+    } else {
+      // Modal mode - use parent handler
+      onEditDocument?.(doc)
+    }
+  }
+
+  // Render create button or inline form
+  const renderCreateArea = () => {
+    if (!showCreateButton) return null
+
+    if (quickCreateMode === 'inline') {
+      const quickFields = getQuickFields('create')
+      return (
+        <form onSubmit={handleInlineSubmit} className="rounded-lg border border-gray-200 bg-gray-50 p-4">
+          {submitError && (
+            <div className="mb-3 rounded bg-red-50 px-3 py-2 text-sm text-red-700">
+              {submitError}
+            </div>
+          )}
+          <div className="flex flex-wrap items-end gap-3">
+            {quickFields.map(f => renderFormField(f, inlineFormData, setInlineFormData, true))}
+            <Button type="submit" size="sm" isLoading={isSubmitting}>
+              <svg className="-ml-1 mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
+              </svg>
+              Add
+            </Button>
+          </div>
+        </form>
+      )
+    }
+
+    // Modal or Page mode - show button
+    return (
+      <Button
+        size="sm"
+        onClick={quickCreateMode === 'page' ? handlePageCreate : onCreateDocument}
+      >
+        <svg className="-ml-1 mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
+        </svg>
+        Add
+      </Button>
+    )
+  }
+
+  // Render inline edit form for a document row
+  const renderInlineEditRow = (doc: Document) => {
+    if (editingDoc?.id !== doc.id) return null
+
+    const quickFields = getQuickFields('edit')
+    return (
+      <tr className="bg-blue-50">
+        <td colSpan={visibleColumns.length + 1} className="px-4 py-3">
+          <form onSubmit={handleInlineEditSubmit}>
+            {updateError && (
+              <div className="mb-3 rounded bg-red-50 px-3 py-2 text-sm text-red-700">
+                {updateError}
+              </div>
+            )}
+            <div className="flex flex-wrap items-end gap-3">
+              {quickFields.map(f => renderFormField(f, editFormData, setEditFormData, true))}
+              <div className="flex gap-2">
+                <Button type="submit" size="sm" isLoading={isUpdating}>
+                  Save
+                </Button>
+                <Button type="button" variant="secondary" size="sm" onClick={cancelInlineEdit} disabled={isUpdating}>
+                  Cancel
+                </Button>
+              </div>
+            </div>
+          </form>
+        </td>
+      </tr>
+    )
+  }
+
   // Render loading state
   if (isLoading && documents.length === 0) {
     return (
@@ -135,191 +529,225 @@ export function DocumentListRenderer({
   // Render table mode
   if (config.displayMode === 'table' || !config.displayMode) {
     return (
-      <div className="space-y-4">
-        {/* Header with create button */}
-        <div className="flex items-center justify-between">
-          <h3 className="text-lg font-medium text-gray-900">{view?.name || 'Documents'}</h3>
-          {config.showCreateButton !== false && canCreateInCollection(collectionName) && (
-            <Button size="sm" onClick={onCreateDocument}>
-              <svg className="-ml-1 mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
-              </svg>
-              Add
-            </Button>
-          )}
-        </div>
+      <>
+        <div className="space-y-4">
+          {/* Header with create button */}
+          <div className="flex items-center justify-between">
+            <h3 className="text-lg font-medium text-gray-900">{view?.name || 'Documents'}</h3>
+            {quickCreateMode !== 'inline' && renderCreateArea()}
+          </div>
+
+          {/* Inline create form */}
+          {quickCreateMode === 'inline' && renderCreateArea()}
 
-        {/* Table */}
-        <div className="overflow-x-auto rounded-lg border border-gray-200">
-          <table className="min-w-full divide-y divide-gray-200">
-            <thead className="bg-gray-50">
-              <tr>
-                {visibleColumns.map((colName) => {
-                  const field = fields.find((f) => f.name === colName)
-                  return (
-                    <th
-                      key={colName}
-                      className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
-                    >
-                      {field?.label || colName}
-                    </th>
-                  )
-                })}
-                <th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
-                  Actions
-                </th>
-              </tr>
-            </thead>
-            <tbody className="divide-y divide-gray-200 bg-white">
-              {documents.length === 0 ? (
+          {/* Table */}
+          <div className="overflow-x-auto rounded-lg border border-gray-200">
+            <table className="min-w-full divide-y divide-gray-200">
+              <thead className="bg-gray-50">
                 <tr>
-                  <td colSpan={visibleColumns.length + 1} className="px-4 py-8 text-center text-gray-500">
-                    No documents found
-                  </td>
+                  {visibleColumns.map((colName) => {
+                    const field = fields.find((f) => f.name === colName)
+                    return (
+                      <th
+                        key={colName}
+                        className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
+                      >
+                        {field?.label || colName}
+                      </th>
+                    )
+                  })}
+                  <th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
+                    Actions
+                  </th>
                 </tr>
-              ) : (
-                documents.map((doc) => (
-                  <tr key={doc.id} className="hover:bg-gray-50">
-                    {visibleColumns.map((colName) => (
-                      <td key={colName} className="whitespace-nowrap px-4 py-3 text-sm text-gray-900">
-                        {formatCellValue(doc.data[colName])}
-                      </td>
-                    ))}
-                    <td className="whitespace-nowrap px-4 py-3 text-right text-sm">
-                      <div className="flex justify-end gap-2">
-                        {canWriteCollection(collectionName) && (
-                          <button
-                            onClick={() => onEditDocument?.(doc)}
-                            className="text-primary-600 hover:text-primary-800"
-                          >
-                            Edit
-                          </button>
-                        )}
-                        {canDeleteInCollection(collectionName) && (
-                          <button
-                            onClick={() => onDeleteDocument?.(doc)}
-                            className="text-red-600 hover:text-red-800"
-                          >
-                            Delete
-                          </button>
-                        )}
-                      </div>
+              </thead>
+              <tbody className="divide-y divide-gray-200 bg-white">
+                {documents.length === 0 ? (
+                  <tr>
+                    <td colSpan={visibleColumns.length + 1} className="px-4 py-8 text-center text-gray-500">
+                      No documents found
                     </td>
                   </tr>
-                ))
-              )}
-            </tbody>
-          </table>
+                ) : (
+                  documents.map((doc) => (
+                    editingDoc?.id === doc.id && quickEditMode === 'inline' ? (
+                      renderInlineEditRow(doc)
+                    ) : (
+                      <tr key={doc.id} className="hover:bg-gray-50">
+                        {visibleColumns.map((colName) => (
+                          <td key={colName} className="whitespace-nowrap px-4 py-3 text-sm text-gray-900">
+                            {formatCellValue(doc.data[colName], fields.find(f => f.name === colName)?.type)}
+                          </td>
+                        ))}
+                        <td className="whitespace-nowrap px-4 py-3 text-right text-sm">
+                          <div className="flex justify-end gap-2">
+                            {showEditButton && (
+                              <button
+                                onClick={() => handleEdit(doc)}
+                                className="text-primary-600 hover:text-primary-800"
+                              >
+                                Edit
+                              </button>
+                            )}
+                            {canDeleteInCollection(collectionName) && (
+                              <button
+                                onClick={() => handleDeleteClick(doc)}
+                                className="text-red-600 hover:text-red-800"
+                              >
+                                Delete
+                              </button>
+                            )}
+                          </div>
+                        </td>
+                      </tr>
+                    )
+                  ))
+                )}
+              </tbody>
+            </table>
+          </div>
+
+          {/* Pagination */}
+          {(page > 1 || hasMore) && (
+            <div className="flex items-center justify-between">
+              <Button
+                variant="secondary"
+                size="sm"
+                onClick={() => setPage((p) => Math.max(1, p - 1))}
+                disabled={page === 1}
+              >
+                Previous
+              </Button>
+              <span className="text-sm text-gray-600">Page {page}</span>
+              <Button
+                variant="secondary"
+                size="sm"
+                onClick={() => setPage((p) => p + 1)}
+                disabled={!hasMore}
+              >
+                Next
+              </Button>
+            </div>
+          )}
         </div>
 
-        {/* Pagination */}
-        {(page > 1 || hasMore) && (
-          <div className="flex items-center justify-between">
-            <Button
-              variant="secondary"
-              size="sm"
-              onClick={() => setPage((p) => Math.max(1, p - 1))}
-              disabled={page === 1}
-            >
-              Previous
-            </Button>
-            <span className="text-sm text-gray-600">Page {page}</span>
-            <Button
-              variant="secondary"
-              size="sm"
-              onClick={() => setPage((p) => p + 1)}
-              disabled={!hasMore}
-            >
-              Next
-            </Button>
-          </div>
-        )}
-      </div>
+        {/* Delete confirmation modal */}
+        <ConfirmModal
+          isOpen={deleteDoc !== null}
+          title="Delete Item"
+          message="Are you sure you want to delete this item? This action cannot be undone."
+          confirmLabel="Delete"
+          variant="danger"
+          isLoading={isDeleting}
+          onConfirm={handleDeleteConfirm}
+          onCancel={() => setDeleteDoc(null)}
+        />
+      </>
     )
   }
 
-  // Render card mode
+  // Render card mode - show all fields
   if (config.displayMode === 'cards') {
-    const titleField = config.cardTemplate?.titleField || visibleColumns[0]
-    const subtitleField = config.cardTemplate?.subtitleField
-    const badgeField = config.cardTemplate?.badgeField
+    // First visible column is title, rest are body fields
+    const titleField = visibleColumns[0]
+    const bodyFields = visibleColumns.slice(1)
 
     return (
-      <div className="space-y-4">
-        {/* Header with create button */}
-        <div className="flex items-center justify-between">
-          <h3 className="text-lg font-medium text-gray-900">{view?.name || 'Documents'}</h3>
-          {config.showCreateButton !== false && canCreateInCollection(collectionName) && (
-            <Button size="sm" onClick={onCreateDocument}>
-              <svg className="-ml-1 mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
-              </svg>
-              Add
-            </Button>
-          )}
-        </div>
-
-        {/* Card grid */}
-        {documents.length === 0 ? (
-          <div className="rounded-lg border-2 border-dashed border-gray-300 p-8 text-center">
-            <p className="text-gray-500">No documents found</p>
+      <>
+        <div className="space-y-4">
+          {/* Header with create button */}
+          <div className="flex items-center justify-between">
+            <h3 className="text-lg font-medium text-gray-900">{view?.name || 'Documents'}</h3>
+            {quickCreateMode !== 'inline' && renderCreateArea()}
           </div>
-        ) : (
-          <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
-            {documents.map((doc) => (
-              <div
-                key={doc.id}
-                className="group rounded-lg border border-gray-200 bg-white p-4 transition hover:border-primary-300 hover:shadow-md"
-              >
-                <div className="flex items-start justify-between">
-                  <div className="flex-1 min-w-0">
-                    <h4 className="truncate font-medium text-gray-900">
-                      {formatCellValue(doc.data[titleField])}
+
+          {/* Inline create form */}
+          {quickCreateMode === 'inline' && renderCreateArea()}
+
+          {/* Card grid */}
+          {documents.length === 0 ? (
+            <div className="rounded-lg border-2 border-dashed border-gray-300 p-8 text-center">
+              <p className="text-gray-500">No documents found</p>
+            </div>
+          ) : (
+            <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
+              {documents.map((doc) => {
+                const field = fields.find(f => f.name === titleField)
+                return (
+                  <div
+                    key={doc.id}
+                    className="group rounded-lg border border-gray-200 bg-white p-4 transition hover:border-primary-300 hover:shadow-md"
+                  >
+                    {/* Title */}
+                    <h4 className="font-medium text-gray-900 truncate">
+                      {formatCellValue(doc.data[titleField], field?.type)}
                     </h4>
-                    {subtitleField && (
-                      <p className="mt-1 truncate text-sm text-gray-500">
-                        {formatCellValue(doc.data[subtitleField])}
-                      </p>
+
+                    {/* Body fields */}
+                    {bodyFields.length > 0 && (
+                      <div className="mt-3 space-y-1.5">
+                        {bodyFields.map(colName => {
+                          const bodyField = fields.find(f => f.name === colName)
+                          const val = doc.data[colName]
+                          if (val === null || val === undefined) return null
+                          return (
+                            <div key={colName} className="flex items-center text-sm">
+                              <span className="text-gray-500 mr-2">{bodyField?.label || colName}:</span>
+                              <span className="text-gray-900 truncate">
+                                {formatCellValue(val, bodyField?.type)}
+                              </span>
+                            </div>
+                          )
+                        })}
+                      </div>
                     )}
+
+                    {/* Card actions */}
+                    <div className="mt-4 flex justify-end gap-2 opacity-0 transition group-hover:opacity-100">
+                      {showEditButton && (
+                        <button
+                          onClick={() => handleEdit(doc)}
+                          className="rounded px-2 py-1 text-xs text-primary-600 hover:bg-primary-50"
+                        >
+                          Edit
+                        </button>
+                      )}
+                      {canDeleteInCollection(collectionName) && (
+                        <button
+                          onClick={() => handleDeleteClick(doc)}
+                          className="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50"
+                        >
+                          Delete
+                        </button>
+                      )}
+                    </div>
                   </div>
-                  {badgeField && Boolean(doc.data[badgeField]) && (
-                    <span className="ml-2 inline-flex flex-shrink-0 items-center rounded-full bg-primary-100 px-2.5 py-0.5 text-xs font-medium text-primary-800">
-                      {formatCellValue(doc.data[badgeField])}
-                    </span>
-                  )}
-                </div>
+                )
+              })}
+            </div>
+          )}
+        </div>
 
-                {/* Card actions */}
-                <div className="mt-4 flex justify-end gap-2 opacity-0 transition group-hover:opacity-100">
-                  {canWriteCollection(collectionName) && (
-                    <button
-                      onClick={() => onEditDocument?.(doc)}
-                      className="rounded px-2 py-1 text-xs text-primary-600 hover:bg-primary-50"
-                    >
-                      Edit
-                    </button>
-                  )}
-                  {canDeleteInCollection(collectionName) && (
-                    <button
-                      onClick={() => onDeleteDocument?.(doc)}
-                      className="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50"
-                    >
-                      Delete
-                    </button>
-                  )}
-                </div>
-              </div>
-            ))}
-          </div>
-        )}
-      </div>
+        {/* Delete confirmation modal */}
+        <ConfirmModal
+          isOpen={deleteDoc !== null}
+          title="Delete Item"
+          message="Are you sure you want to delete this item? This action cannot be undone."
+          confirmLabel="Delete"
+          variant="danger"
+          isLoading={isDeleting}
+          onConfirm={handleDeleteConfirm}
+          onCancel={() => setDeleteDoc(null)}
+        />
+      </>
     )
   }
 
-  // Render kanban mode (simplified version)
+  // Render kanban mode - show all fields in cards
   if (config.displayMode === 'kanban') {
     const groupField = config.kanbanGroupField || visibleColumns[0]
-    const titleField = config.cardTemplate?.titleField || visibleColumns[0]
+    const titleField = visibleColumns.find(c => c !== groupField) || visibleColumns[0]
+    const bodyFields = visibleColumns.filter(c => c !== groupField && c !== titleField)
 
     // Group documents by the kanban field
     const groups: Record<string, Document[]> = {}
@@ -332,61 +760,108 @@ export function DocumentListRenderer({
     })
 
     return (
-      <div className="space-y-4">
-        {/* Header with create button */}
-        <div className="flex items-center justify-between">
-          <h3 className="text-lg font-medium text-gray-900">{view?.name || 'Documents'}</h3>
-          {config.showCreateButton !== false && canCreateInCollection(collectionName) && (
-            <Button size="sm" onClick={onCreateDocument}>
-              <svg className="-ml-1 mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
-              </svg>
-              Add
-            </Button>
-          )}
-        </div>
+      <>
+        <div className="space-y-4">
+          {/* Header with create button */}
+          <div className="flex items-center justify-between">
+            <h3 className="text-lg font-medium text-gray-900">{view?.name || 'Documents'}</h3>
+            {quickCreateMode !== 'inline' && renderCreateArea()}
+          </div>
 
-        {/* Kanban columns */}
-        <div className="flex gap-4 overflow-x-auto pb-4">
-          {Object.entries(groups).map(([groupName, groupDocs]) => (
-            <div
-              key={groupName}
-              className="flex w-72 flex-shrink-0 flex-col rounded-lg bg-gray-100"
-            >
-              <div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
-                <h4 className="font-medium text-gray-900">{groupName}</h4>
-                <span className="rounded-full bg-gray-200 px-2 py-0.5 text-xs text-gray-600">
-                  {groupDocs.length}
-                </span>
-              </div>
-              <div className="flex-1 space-y-2 overflow-y-auto p-2">
-                {groupDocs.map((doc) => (
-                  <div
-                    key={doc.id}
-                    className="group rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition hover:shadow-md"
-                  >
-                    <p className="font-medium text-gray-900">
-                      {formatCellValue(doc.data[titleField])}
-                    </p>
-                    <div className="mt-2 flex justify-end gap-1 opacity-0 transition group-hover:opacity-100">
-                      {canWriteCollection(collectionName) && (
-                        <button
-                          onClick={() => onEditDocument?.(doc)}
-                          className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
-                        >
-                          <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-                            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
-                          </svg>
-                        </button>
-                      )}
-                    </div>
-                  </div>
-                ))}
+          {/* Inline create form */}
+          {quickCreateMode === 'inline' && renderCreateArea()}
+
+          {/* Kanban columns */}
+          <div className="flex gap-4 overflow-x-auto pb-4">
+            {Object.entries(groups).map(([groupName, groupDocs]) => (
+              <div
+                key={groupName}
+                className="flex w-72 flex-shrink-0 flex-col rounded-lg bg-gray-100"
+              >
+                <div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
+                  <h4 className="font-medium text-gray-900">{groupName}</h4>
+                  <span className="rounded-full bg-gray-200 px-2 py-0.5 text-xs text-gray-600">
+                    {groupDocs.length}
+                  </span>
+                </div>
+                <div className="flex-1 space-y-2 overflow-y-auto p-2">
+                  {groupDocs.map((doc) => {
+                    const field = fields.find(f => f.name === titleField)
+                    return (
+                      <div
+                        key={doc.id}
+                        className="group rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition hover:shadow-md"
+                      >
+                        {/* Title */}
+                        <p className="font-medium text-gray-900">
+                          {formatCellValue(doc.data[titleField], field?.type)}
+                        </p>
+
+                        {/* Body fields */}
+                        {bodyFields.length > 0 && (
+                          <div className="mt-2 space-y-1">
+                            {bodyFields.map(colName => {
+                              const bodyField = fields.find(f => f.name === colName)
+                              const val = doc.data[colName]
+                              if (val === null || val === undefined) return null
+                              return (
+                                <div key={colName} className="text-xs text-gray-500">
+                                  <span>{bodyField?.label || colName}: </span>
+                                  <span className="text-gray-700">
+                                    {formatCellValue(val, bodyField?.type)}
+                                  </span>
+                                </div>
+                              )
+                            })}
+                          </div>
+                        )}
+
+                        {/* Actions */}
+                        <div className="mt-2 flex justify-end gap-1 opacity-0 transition group-hover:opacity-100">
+                          {showEditButton && (
+                            <button
+                              onClick={() => handleEdit(doc)}
+                              className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
+                              title="Edit"
+                            >
+                              <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
+                              </svg>
+                            </button>
+                          )}
+                          {canDeleteInCollection(collectionName) && (
+                            <button
+                              onClick={() => handleDeleteClick(doc)}
+                              className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
+                              title="Delete"
+                            >
+                              <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
+                              </svg>
+                            </button>
+                          )}
+                        </div>
+                      </div>
+                    )
+                  })}
+                </div>
               </div>
-            </div>
-          ))}
+            ))}
+          </div>
         </div>
-      </div>
+
+        {/* Delete confirmation modal */}
+        <ConfirmModal
+          isOpen={deleteDoc !== null}
+          title="Delete Item"
+          message="Are you sure you want to delete this item? This action cannot be undone."
+          confirmLabel="Delete"
+          variant="danger"
+          isLoading={isDeleting}
+          onConfirm={handleDeleteConfirm}
+          onCancel={() => setDeleteDoc(null)}
+        />
+      </>
     )
   }
 
@@ -394,13 +869,16 @@ export function DocumentListRenderer({
 }
 
 // Helper function to format cell values for display
-function formatCellValue(value: unknown): string {
+function formatCellValue(value: unknown, fieldType?: string): string {
   if (value === null || value === undefined) {
     return '-'
   }
   if (typeof value === 'boolean') {
     return value ? 'Yes' : 'No'
   }
+  if (fieldType === 'color' && typeof value === 'string' && value.startsWith('#')) {
+    return value // Could render as color swatch in future
+  }
   if (value instanceof Date) {
     return value.toLocaleDateString()
   }

+ 737 - 40
webui/src/components/PageRenderer/index.tsx

@@ -10,6 +10,8 @@ import TextBlockRenderer from './TextBlockRenderer'
 import SpacerRenderer from './SpacerRenderer'
 import DocumentListRenderer from './DocumentListRenderer'
 import StatCardRenderer from './StatCardRenderer'
+import Button from '@/components/Button'
+import Input from '@/components/Input'
 import type {
   Page,
   LayoutComponent,
@@ -18,23 +20,55 @@ import type {
   SpacerConfig,
   DocumentListConfig,
   StatCardConfig,
+  Document,
 } from '@/types'
 
+interface ViewField {
+  name: string
+  label: string
+  type: string
+  required?: boolean
+  options?: Array<{ value: string; label: string }> | string[]
+}
+
+// Normalize options to {value, label} format
+function normalizeOptions(options: Array<{ value: string; label: string }> | string[] | undefined): Array<{ value: string; label: string }> {
+  if (!options) return []
+  return options.map((opt): { value: string; label: string } => {
+    if (typeof opt === 'string') {
+      return { value: opt, label: opt }
+    }
+    return { value: opt.value, label: opt.label }
+  })
+}
+
 interface View {
   id: string
+  name: string
   collection_name: string
+  schema?: {
+    fields?: ViewField[]
+  }
+  settings?: {
+    quick_create_fields?: string[]  // Fields to show in quick create form
+    quick_edit_fields?: string[]    // Fields to show in quick edit form
+    show_create_button?: boolean
+    show_edit_button?: boolean
+  }
 }
 
 interface PageRendererProps {
   page: Page
   isEditing?: boolean
   onComponentClick?: (component: LayoutComponent) => void
+  onReorder?: (components: LayoutComponent[]) => void
+  selectedComponentId?: string | null
 }
 
-// Cache for view collection lookups
-const viewCollectionCache: Record<string, string> = {}
+// Cache for view lookups
+const viewCache: Record<string, View> = {}
 
-export function PageRenderer({ page, isEditing = false, onComponentClick }: PageRendererProps) {
+export function PageRenderer({ page, isEditing = false, onComponentClick, onReorder, selectedComponentId }: PageRendererProps) {
   const { currentWorkspace } = useWorkspace()
   const workspaceId = currentWorkspace?.id || ''
   const { canReadCollection } = usePermissions({ workspaceId })
@@ -42,22 +76,42 @@ export function PageRenderer({ page, isEditing = false, onComponentClick }: Page
   const [visibleComponents, setVisibleComponents] = useState<LayoutComponent[]>([])
   const [isLoading, setIsLoading] = useState(true)
 
-  // Get collection name for a view (with caching)
-  const getViewCollection = useCallback(async (viewId: string): Promise<string | null> => {
+  // Drag & drop state
+  const [draggedIndex, setDraggedIndex] = useState<number | null>(null)
+  const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
+
+  // Create document modal state
+  const [createModalView, setCreateModalView] = useState<View | null>(null)
+  const [createFormData, setCreateFormData] = useState<Record<string, unknown>>({})
+  const [isCreating, setIsCreating] = useState(false)
+  const [createError, setCreateError] = useState<string | null>(null)
+
+  // Edit document modal state
+  const [editModalView, setEditModalView] = useState<View | null>(null)
+  const [editingDoc, setEditingDoc] = useState<Document | null>(null)
+  const [editFormData, setEditFormData] = useState<Record<string, unknown>>({})
+  const [isUpdating, setIsUpdating] = useState(false)
+  const [updateError, setUpdateError] = useState<string | null>(null)
+
+  // Track refresh triggers for document lists
+  const [refreshTrigger, setRefreshTrigger] = useState(0)
+
+  // Get view details (with caching)
+  const getView = useCallback(async (viewId: string): Promise<View | null> => {
     if (!workspaceId) return null
 
     // Check cache
     const cacheKey = `${workspaceId}:${viewId}`
-    if (viewCollectionCache[cacheKey]) {
-      return viewCollectionCache[cacheKey]
+    if (viewCache[cacheKey]) {
+      return viewCache[cacheKey]
     }
 
     try {
       const view = await apiClient.get<View>(
         `/workspaces/${workspaceId}/views/${viewId}`
       )
-      viewCollectionCache[cacheKey] = view.collection_name
-      return view.collection_name
+      viewCache[cacheKey] = view
+      return view
     } catch {
       return null
     }
@@ -78,8 +132,8 @@ export function PageRenderer({ page, isEditing = false, onComponentClick }: Page
       if (component.type === 'document-list' || component.type === 'stat-card') {
         const config = component.config as unknown as { viewId?: string }
         if (config.viewId) {
-          const collectionName = await getViewCollection(config.viewId)
-          if (collectionName && canReadCollection(collectionName)) {
+          const view = await getView(config.viewId)
+          if (view && canReadCollection(view.collection_name)) {
             filtered.push(component)
           }
         }
@@ -93,7 +147,7 @@ export function PageRenderer({ page, isEditing = false, onComponentClick }: Page
     }
 
     return filtered
-  }, [getViewCollection, canReadCollection, isEditing])
+  }, [getView, canReadCollection, isEditing])
 
   // Load and filter components
   useEffect(() => {
@@ -118,19 +172,522 @@ export function PageRenderer({ page, isEditing = false, onComponentClick }: Page
     })
   }, [visibleComponents])
 
+  // Handle create document
+  const handleCreateDocument = useCallback(async (viewId: string) => {
+    const view = await getView(viewId)
+    if (view) {
+      setCreateModalView(view)
+      setCreateFormData({})
+      setCreateError(null)
+    }
+  }, [getView])
+
+  // Submit create document
+  const handleSubmitCreate = useCallback(async () => {
+    if (!createModalView || !workspaceId) return
+
+    setIsCreating(true)
+    setCreateError(null)
+
+    try {
+      await apiClient.post(
+        `/workspaces/${workspaceId}/collections/${createModalView.collection_name}/documents`,
+        { data: createFormData }
+      )
+      setCreateModalView(null)
+      setCreateFormData({})
+      // Trigger refresh of document lists
+      setRefreshTrigger(prev => prev + 1)
+    } catch (err) {
+      setCreateError(err instanceof Error ? err.message : 'Failed to create document')
+    } finally {
+      setIsCreating(false)
+    }
+  }, [createModalView, workspaceId, createFormData])
+
+  // Handle edit document - opens modal with document data
+  const handleEditDocument = useCallback(async (doc: Document, viewId: string) => {
+    const view = await getView(viewId)
+    if (view) {
+      setEditModalView(view)
+      setEditingDoc(doc)
+      setEditFormData({ ...doc.data })
+      setUpdateError(null)
+    }
+  }, [getView])
+
+  // Submit edit document
+  const handleSubmitEdit = useCallback(async () => {
+    if (!editModalView || !editingDoc || !workspaceId) return
+
+    setIsUpdating(true)
+    setUpdateError(null)
+
+    try {
+      await apiClient.patch(
+        `/workspaces/${workspaceId}/collections/${editModalView.collection_name}/documents/${editingDoc.id}`,
+        { data: editFormData }
+      )
+      setEditModalView(null)
+      setEditingDoc(null)
+      setEditFormData({})
+      // Trigger refresh of document lists
+      setRefreshTrigger(prev => prev + 1)
+    } catch (err) {
+      setUpdateError(err instanceof Error ? err.message : 'Failed to update document')
+    } finally {
+      setIsUpdating(false)
+    }
+  }, [editModalView, editingDoc, workspaceId, editFormData])
+
+  // Handle delete document
+  const handleDeleteDocument = useCallback(async (doc: Document, collectionName: string) => {
+    if (!workspaceId || !confirm('Are you sure you want to delete this item?')) return
+
+    try {
+      await apiClient.delete(
+        `/workspaces/${workspaceId}/collections/${collectionName}/documents/${doc.id}`
+      )
+      setRefreshTrigger(prev => prev + 1)
+    } catch (err) {
+      console.error('Failed to delete document:', err)
+    }
+  }, [workspaceId])
+
+  // Render form field for create modal
+  const renderFormField = (field: ViewField) => {
+    const value = createFormData[field.name] ?? ''
+
+    if (field.type === 'select' && field.options) {
+      const opts = normalizeOptions(field.options)
+      return (
+        <div key={field.name}>
+          <label className="mb-1.5 block text-sm font-medium text-gray-700">
+            {field.label}
+            {field.required && <span className="text-red-500 ml-1">*</span>}
+          </label>
+          <select
+            value={String(value)}
+            onChange={(e) => setCreateFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2"
+            required={field.required}
+          >
+            <option value="">Select...</option>
+            {opts.map(opt => (
+              <option key={opt.value} value={opt.value}>{opt.label}</option>
+            ))}
+          </select>
+        </div>
+      )
+    }
+
+    if (field.type === 'boolean' || field.type === 'checkbox') {
+      return (
+        <div key={field.name}>
+          <label className="flex items-center gap-2">
+            <input
+              type="checkbox"
+              checked={Boolean(value)}
+              onChange={(e) => setCreateFormData(prev => ({ ...prev, [field.name]: e.target.checked }))}
+              className="h-4 w-4 rounded border-gray-300"
+            />
+            <span className="text-sm text-gray-700">{field.label}</span>
+          </label>
+        </div>
+      )
+    }
+
+    if (field.type === 'date') {
+      return (
+        <div key={field.name}>
+          <label className="mb-1.5 block text-sm font-medium text-gray-700">
+            {field.label}
+            {field.required && <span className="text-red-500 ml-1">*</span>}
+          </label>
+          <input
+            type="date"
+            value={String(value)}
+            onChange={(e) => setCreateFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2"
+            required={field.required}
+          />
+        </div>
+      )
+    }
+
+    if (field.type === 'datetime') {
+      return (
+        <div key={field.name}>
+          <label className="mb-1.5 block text-sm font-medium text-gray-700">
+            {field.label}
+            {field.required && <span className="text-red-500 ml-1">*</span>}
+          </label>
+          <input
+            type="datetime-local"
+            value={String(value)}
+            onChange={(e) => setCreateFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2"
+            required={field.required}
+          />
+        </div>
+      )
+    }
+
+    if (field.type === 'color') {
+      return (
+        <div key={field.name}>
+          <label className="mb-1.5 block text-sm font-medium text-gray-700">
+            {field.label}
+            {field.required && <span className="text-red-500 ml-1">*</span>}
+          </label>
+          <div className="flex items-center gap-3">
+            <input
+              type="color"
+              value={String(value) || '#000000'}
+              onChange={(e) => setCreateFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
+              className="h-10 w-14 cursor-pointer rounded border border-gray-300 p-1"
+              required={field.required}
+            />
+            <input
+              type="text"
+              value={String(value)}
+              onChange={(e) => setCreateFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
+              placeholder="#000000"
+              className="flex-1 rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm"
+            />
+          </div>
+        </div>
+      )
+    }
+
+    if (field.type === 'email') {
+      return (
+        <div key={field.name}>
+          <label className="mb-1.5 block text-sm font-medium text-gray-700">
+            {field.label}
+            {field.required && <span className="text-red-500 ml-1">*</span>}
+          </label>
+          <input
+            type="email"
+            value={String(value)}
+            onChange={(e) => setCreateFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2"
+            placeholder="email@example.com"
+            required={field.required}
+          />
+        </div>
+      )
+    }
+
+    if (field.type === 'url') {
+      return (
+        <div key={field.name}>
+          <label className="mb-1.5 block text-sm font-medium text-gray-700">
+            {field.label}
+            {field.required && <span className="text-red-500 ml-1">*</span>}
+          </label>
+          <input
+            type="url"
+            value={String(value)}
+            onChange={(e) => setCreateFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2"
+            placeholder="https://example.com"
+            required={field.required}
+          />
+        </div>
+      )
+    }
+
+    if (field.type === 'number') {
+      return (
+        <div key={field.name}>
+          <label className="mb-1.5 block text-sm font-medium text-gray-700">
+            {field.label}
+            {field.required && <span className="text-red-500 ml-1">*</span>}
+          </label>
+          <input
+            type="number"
+            value={String(value)}
+            onChange={(e) => setCreateFormData(prev => ({ ...prev, [field.name]: parseFloat(e.target.value) || 0 }))}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2"
+            required={field.required}
+          />
+        </div>
+      )
+    }
+
+    if (field.type === 'textarea' || field.type === 'text-long') {
+      return (
+        <div key={field.name}>
+          <label className="mb-1.5 block text-sm font-medium text-gray-700">
+            {field.label}
+            {field.required && <span className="text-red-500 ml-1">*</span>}
+          </label>
+          <textarea
+            value={String(value)}
+            onChange={(e) => setCreateFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2"
+            rows={3}
+            required={field.required}
+          />
+        </div>
+      )
+    }
+
+    // Default: text input
+    return (
+      <Input
+        key={field.name}
+        label={field.label + (field.required ? ' *' : '')}
+        value={String(value)}
+        onChange={(e) => setCreateFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
+        required={field.required}
+      />
+    )
+  }
+
+  // Render form field for edit modal
+  const renderEditFormField = (field: ViewField) => {
+    const value = editFormData[field.name] ?? ''
+
+    if (field.type === 'select' && field.options) {
+      const opts = normalizeOptions(field.options)
+      return (
+        <div key={field.name}>
+          <label className="mb-1.5 block text-sm font-medium text-gray-700">
+            {field.label}
+            {field.required && <span className="text-red-500 ml-1">*</span>}
+          </label>
+          <select
+            value={String(value)}
+            onChange={(e) => setEditFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2"
+            required={field.required}
+          >
+            <option value="">Select...</option>
+            {opts.map(opt => (
+              <option key={opt.value} value={opt.value}>{opt.label}</option>
+            ))}
+          </select>
+        </div>
+      )
+    }
+
+    if (field.type === 'boolean' || field.type === 'checkbox') {
+      return (
+        <div key={field.name}>
+          <label className="flex items-center gap-2">
+            <input
+              type="checkbox"
+              checked={Boolean(value)}
+              onChange={(e) => setEditFormData(prev => ({ ...prev, [field.name]: e.target.checked }))}
+              className="h-4 w-4 rounded border-gray-300"
+            />
+            <span className="text-sm text-gray-700">{field.label}</span>
+          </label>
+        </div>
+      )
+    }
+
+    if (field.type === 'date') {
+      return (
+        <div key={field.name}>
+          <label className="mb-1.5 block text-sm font-medium text-gray-700">
+            {field.label}
+            {field.required && <span className="text-red-500 ml-1">*</span>}
+          </label>
+          <input
+            type="date"
+            value={String(value)}
+            onChange={(e) => setEditFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2"
+            required={field.required}
+          />
+        </div>
+      )
+    }
+
+    if (field.type === 'datetime') {
+      return (
+        <div key={field.name}>
+          <label className="mb-1.5 block text-sm font-medium text-gray-700">
+            {field.label}
+            {field.required && <span className="text-red-500 ml-1">*</span>}
+          </label>
+          <input
+            type="datetime-local"
+            value={String(value)}
+            onChange={(e) => setEditFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2"
+            required={field.required}
+          />
+        </div>
+      )
+    }
+
+    if (field.type === 'color') {
+      return (
+        <div key={field.name}>
+          <label className="mb-1.5 block text-sm font-medium text-gray-700">
+            {field.label}
+            {field.required && <span className="text-red-500 ml-1">*</span>}
+          </label>
+          <div className="flex items-center gap-3">
+            <input
+              type="color"
+              value={String(value) || '#000000'}
+              onChange={(e) => setEditFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
+              className="h-10 w-14 cursor-pointer rounded border border-gray-300 p-1"
+              required={field.required}
+            />
+            <input
+              type="text"
+              value={String(value)}
+              onChange={(e) => setEditFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
+              placeholder="#000000"
+              className="flex-1 rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm"
+            />
+          </div>
+        </div>
+      )
+    }
+
+    if (field.type === 'number') {
+      return (
+        <div key={field.name}>
+          <label className="mb-1.5 block text-sm font-medium text-gray-700">
+            {field.label}
+            {field.required && <span className="text-red-500 ml-1">*</span>}
+          </label>
+          <input
+            type="number"
+            value={String(value)}
+            onChange={(e) => setEditFormData(prev => ({ ...prev, [field.name]: parseFloat(e.target.value) || 0 }))}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2"
+            required={field.required}
+          />
+        </div>
+      )
+    }
+
+    // Default: text input
+    return (
+      <Input
+        key={field.name}
+        label={field.label + (field.required ? ' *' : '')}
+        value={String(value)}
+        onChange={(e) => setEditFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
+        required={field.required}
+      />
+    )
+  }
+
+  // Get fields to show in create modal
+  const getCreateFields = (view: View): ViewField[] => {
+    const allFields = view.schema?.fields || []
+    const quickCreateFields = view.settings?.quick_create_fields
+
+    if (quickCreateFields && quickCreateFields.length > 0) {
+      // Return only the specified quick create fields in order
+      return quickCreateFields
+        .map(name => allFields.find(f => f.name === name))
+        .filter((f): f is ViewField => f !== undefined)
+    }
+
+    // Default: return all fields
+    return allFields
+  }
+
+  // Get fields to show in edit modal
+  const getEditFields = (view: View): ViewField[] => {
+    const allFields = view.schema?.fields || []
+    const quickEditFields = view.settings?.quick_edit_fields
+
+    if (quickEditFields && quickEditFields.length > 0) {
+      // Return only the specified quick edit fields in order
+      return quickEditFields
+        .map(name => allFields.find(f => f.name === name))
+        .filter((f): f is ViewField => f !== undefined)
+    }
+
+    // Default: return all fields
+    return allFields
+  }
+
+  // Drag handlers
+  const handleDragStart = useCallback((index: number) => (e: React.DragEvent) => {
+    setDraggedIndex(index)
+    e.dataTransfer.effectAllowed = 'move'
+    e.dataTransfer.setData('text/plain', String(index))
+    // Add dragging class after a small delay to avoid flash
+    setTimeout(() => {
+      const target = e.target as HTMLElement
+      target.style.opacity = '0.5'
+    }, 0)
+  }, [])
+
+  const handleDragEnd = useCallback((e: React.DragEvent) => {
+    setDraggedIndex(null)
+    setDragOverIndex(null)
+    const target = e.target as HTMLElement
+    target.style.opacity = '1'
+  }, [])
+
+  const handleDragOver = useCallback((index: number) => (e: React.DragEvent) => {
+    e.preventDefault()
+    e.dataTransfer.dropEffect = 'move'
+    if (draggedIndex !== null && index !== draggedIndex) {
+      setDragOverIndex(index)
+    }
+  }, [draggedIndex])
+
+  const handleDragLeave = useCallback(() => {
+    setDragOverIndex(null)
+  }, [])
+
+  const handleDrop = useCallback((targetIndex: number) => (e: React.DragEvent) => {
+    e.preventDefault()
+    if (draggedIndex === null || draggedIndex === targetIndex || !onReorder) {
+      setDraggedIndex(null)
+      setDragOverIndex(null)
+      return
+    }
+
+    // Reorder components
+    const newComponents = [...sortedComponents]
+    const [removed] = newComponents.splice(draggedIndex, 1)
+    newComponents.splice(targetIndex, 0, removed)
+
+    // Update y positions to match new order
+    const updatedComponents = newComponents.map((comp, idx) => ({
+      ...comp,
+      position: { ...comp.position, y: idx }
+    }))
+
+    onReorder(updatedComponents)
+    setDraggedIndex(null)
+    setDragOverIndex(null)
+  }, [draggedIndex, sortedComponents, onReorder])
+
   // Render a single component
-  const renderComponent = (component: LayoutComponent) => {
-    const style = isEditing ? {
-      gridColumn: `${component.position.x + 1} / span ${component.position.width}`,
-      gridRow: `${component.position.y + 1} / span ${component.position.height}`,
-    } : undefined
+  const renderComponent = (component: LayoutComponent, index: number) => {
+    const isSelected = selectedComponentId === component.id
+    const isDragging = draggedIndex === index
+    const isDragOver = dragOverIndex === index
 
     const wrapperClass = isEditing
-      ? 'cursor-pointer rounded border border-dashed border-transparent hover:border-primary-300 hover:bg-primary-50/50 transition'
+      ? `relative rounded-lg border-2 transition ${
+          isSelected
+            ? 'border-primary-500 bg-primary-50/50'
+            : isDragOver
+            ? 'border-primary-400 border-dashed bg-primary-50/30'
+            : 'border-dashed border-transparent hover:border-gray-300 hover:bg-gray-50/50'
+        } ${isDragging ? 'opacity-50' : ''}`
       : ''
 
-    const handleClick = () => {
+    const handleClick = (e: React.MouseEvent) => {
       if (isEditing && onComponentClick) {
+        e.stopPropagation()
         onComponentClick(component)
       }
     }
@@ -143,8 +700,25 @@ export function PageRenderer({ page, isEditing = false, onComponentClick }: Page
           return <TextBlockRenderer config={component.config as unknown as TextBlockConfig} />
         case 'spacer':
           return <SpacerRenderer config={component.config as unknown as SpacerConfig} />
-        case 'document-list':
-          return <DocumentListRenderer config={component.config as unknown as DocumentListConfig} />
+        case 'document-list': {
+          const config = component.config as unknown as DocumentListConfig
+          return (
+            <DocumentListRenderer
+              key={`${component.id}-${refreshTrigger}`}
+              config={config}
+              onCreateDocument={() => config.viewId && handleCreateDocument(config.viewId)}
+              onEditDocument={(doc) => config.viewId && handleEditDocument(doc, config.viewId)}
+              onDeleteDocument={async (doc) => {
+                if (config.viewId) {
+                  const view = await getView(config.viewId)
+                  if (view) {
+                    handleDeleteDocument(doc, view.collection_name)
+                  }
+                }
+              }}
+            />
+          )
+        }
         case 'stat-card':
           return <StatCardRenderer config={component.config as unknown as StatCardConfig} />
         default:
@@ -156,14 +730,47 @@ export function PageRenderer({ page, isEditing = false, onComponentClick }: Page
       }
     })()
 
+    if (!isEditing) {
+      return (
+        <div key={component.id}>
+          {content}
+        </div>
+      )
+    }
+
     return (
       <div
         key={component.id}
         className={wrapperClass}
-        style={style}
+        draggable
+        onDragStart={handleDragStart(index)}
+        onDragEnd={handleDragEnd}
+        onDragOver={handleDragOver(index)}
+        onDragLeave={handleDragLeave}
+        onDrop={handleDrop(index)}
         onClick={handleClick}
       >
-        {content}
+        {/* Drag handle */}
+        <div className="absolute left-2 top-1/2 -translate-y-1/2 cursor-grab rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 active:cursor-grabbing">
+          <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8h16M4 16h16" />
+          </svg>
+        </div>
+
+        {/* Drop indicator - top */}
+        {isDragOver && draggedIndex !== null && draggedIndex > index && (
+          <div className="absolute -top-1 left-0 right-0 h-1 rounded bg-primary-500" />
+        )}
+
+        {/* Component content */}
+        <div className="pl-10 pr-2 py-2">
+          {content}
+        </div>
+
+        {/* Drop indicator - bottom */}
+        {isDragOver && draggedIndex !== null && draggedIndex < index && (
+          <div className="absolute -bottom-1 left-0 right-0 h-1 rounded bg-primary-500" />
+        )}
       </div>
     )
   }
@@ -213,25 +820,115 @@ export function PageRenderer({ page, isEditing = false, onComponentClick }: Page
     )
   }
 
-  // Render in grid mode when editing, or stacked mode for display
-  if (isEditing) {
-    return (
-      <div
-        className="grid gap-4"
-        style={{
-          gridTemplateColumns: `repeat(${page.layout?.grid_columns || 12}, 1fr)`,
-        }}
-      >
-        {sortedComponents.map(renderComponent)}
+  return (
+    <>
+      {/* Page content - always use stacked layout for simplicity */}
+      <div className="space-y-4">
+        {sortedComponents.map((component, index) => renderComponent(component, index))}
       </div>
-    )
-  }
 
-  // Render stacked layout for display mode
-  return (
-    <div className="space-y-6">
-      {sortedComponents.map(renderComponent)}
-    </div>
+      {/* Create Document Modal */}
+      {createModalView && (
+        <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/50 p-4">
+          <div className="w-full max-w-lg rounded-lg bg-white shadow-xl">
+            <div className="flex items-center justify-between border-b px-6 py-4">
+              <h2 className="text-lg font-semibold text-gray-900">
+                Add to {createModalView.name}
+              </h2>
+              <button
+                onClick={() => setCreateModalView(null)}
+                className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
+              >
+                <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+                </svg>
+              </button>
+            </div>
+
+            <form onSubmit={(e) => { e.preventDefault(); handleSubmitCreate(); }}>
+              <div className="space-y-4 p-6">
+                {createError && (
+                  <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
+                    {createError}
+                  </div>
+                )}
+
+                {getCreateFields(createModalView).map(renderFormField)}
+              </div>
+
+              <div className="flex justify-end gap-3 border-t bg-gray-50 px-6 py-4">
+                <Button
+                  type="button"
+                  variant="secondary"
+                  onClick={() => setCreateModalView(null)}
+                  disabled={isCreating}
+                >
+                  Cancel
+                </Button>
+                <Button type="submit" isLoading={isCreating}>
+                  Create
+                </Button>
+              </div>
+            </form>
+          </div>
+        </div>
+      )}
+
+      {/* Edit Document Modal */}
+      {editModalView && editingDoc && (
+        <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/50 p-4">
+          <div className="w-full max-w-lg rounded-lg bg-white shadow-xl">
+            <div className="flex items-center justify-between border-b px-6 py-4">
+              <h2 className="text-lg font-semibold text-gray-900">
+                Edit in {editModalView.name}
+              </h2>
+              <button
+                onClick={() => {
+                  setEditModalView(null)
+                  setEditingDoc(null)
+                  setEditFormData({})
+                }}
+                className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
+              >
+                <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+                </svg>
+              </button>
+            </div>
+
+            <form onSubmit={(e) => { e.preventDefault(); handleSubmitEdit(); }}>
+              <div className="space-y-4 p-6">
+                {updateError && (
+                  <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
+                    {updateError}
+                  </div>
+                )}
+
+                {getEditFields(editModalView).map(field => renderEditFormField(field))}
+              </div>
+
+              <div className="flex justify-end gap-3 border-t bg-gray-50 px-6 py-4">
+                <Button
+                  type="button"
+                  variant="secondary"
+                  onClick={() => {
+                    setEditModalView(null)
+                    setEditingDoc(null)
+                    setEditFormData({})
+                  }}
+                  disabled={isUpdating}
+                >
+                  Cancel
+                </Button>
+                <Button type="submit" isLoading={isUpdating}>
+                  Save Changes
+                </Button>
+              </div>
+            </form>
+          </div>
+        </div>
+      )}
+    </>
   )
 }
 

+ 2 - 2
webui/src/hooks/useSidebarPages.ts

@@ -44,11 +44,11 @@ export function useSidebarPages({
       // Backend already filters by:
       // 1. User permissions (read_all, owns, or in shared_with_groups)
       // 2. show_in_sidebar = true
-      const response = await apiClient.get<SidebarPage[]>(
+      const response = await apiClient.get<{ pages: SidebarPage[] }>(
         `/workspaces/${workspaceId}/pages/sidebar`
       )
       // Pages are already sorted by menu_order from the backend
-      setPages(response || [])
+      setPages(response.pages || [])
     } catch (err) {
       setError(err instanceof Error ? err.message : 'Failed to fetch sidebar pages')
       setPages([])

+ 331 - 0
webui/src/pages/DocumentForm.tsx

@@ -0,0 +1,331 @@
+// Full-page document create/edit form
+import { useState, useEffect, useCallback } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import apiClient from '@/api/client'
+import { useWorkspace } from '@/contexts/WorkspaceContext'
+import Button from '@/components/Button'
+
+interface ViewField {
+  name: string
+  type: string
+  label: string
+  description?: string
+  required?: boolean
+  options?: Array<{ value: string; label: string }> | string[]
+}
+
+interface View {
+  id: string
+  name: string
+  collection_name: string
+  workspace_id: string
+  schema?: {
+    fields?: ViewField[]
+  }
+}
+
+interface Document {
+  id: string
+  data: Record<string, unknown>
+}
+
+// Normalize options to {value, label} format
+function normalizeOptions(options: Array<{ value: string; label: string }> | string[] | undefined): Array<{ value: string; label: string }> {
+  if (!options) return []
+  return options.map(opt => {
+    if (typeof opt === 'string') {
+      return { value: opt, label: opt }
+    }
+    return opt
+  })
+}
+
+export default function DocumentForm() {
+  const { viewId, docId } = useParams<{ viewId: string; docId?: string }>()
+  const navigate = useNavigate()
+  const { currentWorkspace } = useWorkspace()
+
+  const [view, setView] = useState<View | null>(null)
+  const [document, setDocument] = useState<Document | null>(null)
+  const [formData, setFormData] = useState<Record<string, unknown>>({})
+  const [isLoading, setIsLoading] = useState(true)
+  const [isSaving, setIsSaving] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  const isEdit = !!docId
+
+  // Fetch view and document data
+  useEffect(() => {
+    const fetchData = async () => {
+      if (!currentWorkspace || !viewId) return
+
+      setIsLoading(true)
+      setError(null)
+
+      try {
+        // Fetch view
+        const viewResponse = await apiClient.get<View>(
+          `/workspaces/${currentWorkspace.id}/views/${viewId}`
+        )
+        setView(viewResponse)
+
+        // If editing, fetch document
+        if (docId) {
+          const docResponse = await apiClient.get<Document>(
+            `/workspaces/${currentWorkspace.id}/collections/${viewResponse.collection_name}/documents/${docId}`
+          )
+          setDocument(docResponse)
+          setFormData(docResponse.data || {})
+        }
+      } catch (err) {
+        setError(err instanceof Error ? err.message : 'Failed to load data')
+      } finally {
+        setIsLoading(false)
+      }
+    }
+
+    fetchData()
+  }, [currentWorkspace, viewId, docId])
+
+  const handleSubmit = useCallback(async (e: React.FormEvent) => {
+    e.preventDefault()
+    if (!view || !currentWorkspace) return
+
+    setIsSaving(true)
+    setError(null)
+
+    try {
+      if (isEdit && document) {
+        await apiClient.patch(
+          `/workspaces/${currentWorkspace.id}/collections/${view.collection_name}/documents/${document.id}`,
+          { data: formData }
+        )
+      } else {
+        await apiClient.post(
+          `/workspaces/${currentWorkspace.id}/collections/${view.collection_name}/documents`,
+          { data: formData }
+        )
+      }
+      navigate(-1) // Go back
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to save document')
+    } finally {
+      setIsSaving(false)
+    }
+  }, [view, currentWorkspace, document, formData, isEdit, navigate])
+
+  const handleCancel = () => {
+    navigate(-1)
+  }
+
+  const handleFieldChange = (fieldName: string, value: unknown) => {
+    setFormData(prev => ({ ...prev, [fieldName]: value }))
+  }
+
+  const renderField = (field: ViewField) => {
+    const value = formData[field.name] ?? ''
+
+    if (field.type === 'select' && field.options) {
+      const opts = normalizeOptions(field.options)
+      return (
+        <div key={field.name} className="space-y-1.5">
+          <label className="block text-sm font-medium text-gray-700">
+            {field.label}
+            {field.required && <span className="text-red-500 ml-1">*</span>}
+          </label>
+          {field.description && (
+            <p className="text-xs text-gray-500">{field.description}</p>
+          )}
+          <select
+            value={String(value)}
+            onChange={(e) => handleFieldChange(field.name, e.target.value)}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
+            required={field.required}
+          >
+            <option value="">Select...</option>
+            {opts.map(opt => (
+              <option key={opt.value} value={opt.value}>{opt.label}</option>
+            ))}
+          </select>
+        </div>
+      )
+    }
+
+    if (field.type === 'boolean' || field.type === 'checkbox') {
+      return (
+        <div key={field.name} className="space-y-1.5">
+          <label className="flex items-center gap-2">
+            <input
+              type="checkbox"
+              checked={Boolean(value)}
+              onChange={(e) => handleFieldChange(field.name, e.target.checked)}
+              className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
+            />
+            <span className="text-sm font-medium text-gray-700">{field.label}</span>
+          </label>
+          {field.description && (
+            <p className="ml-6 text-xs text-gray-500">{field.description}</p>
+          )}
+        </div>
+      )
+    }
+
+    if (field.type === 'textarea' || field.type === 'text_long') {
+      return (
+        <div key={field.name} className="space-y-1.5">
+          <label className="block text-sm font-medium text-gray-700">
+            {field.label}
+            {field.required && <span className="text-red-500 ml-1">*</span>}
+          </label>
+          {field.description && (
+            <p className="text-xs text-gray-500">{field.description}</p>
+          )}
+          <textarea
+            value={String(value)}
+            onChange={(e) => handleFieldChange(field.name, e.target.value)}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
+            rows={4}
+            required={field.required}
+          />
+        </div>
+      )
+    }
+
+    if (field.type === 'number' || field.type === 'integer') {
+      return (
+        <div key={field.name} className="space-y-1.5">
+          <label className="block text-sm font-medium text-gray-700">
+            {field.label}
+            {field.required && <span className="text-red-500 ml-1">*</span>}
+          </label>
+          {field.description && (
+            <p className="text-xs text-gray-500">{field.description}</p>
+          )}
+          <input
+            type="number"
+            value={value === '' ? '' : Number(value)}
+            onChange={(e) => handleFieldChange(field.name, e.target.value === '' ? '' : Number(e.target.value))}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
+            required={field.required}
+          />
+        </div>
+      )
+    }
+
+    if (field.type === 'date') {
+      return (
+        <div key={field.name} className="space-y-1.5">
+          <label className="block text-sm font-medium text-gray-700">
+            {field.label}
+            {field.required && <span className="text-red-500 ml-1">*</span>}
+          </label>
+          {field.description && (
+            <p className="text-xs text-gray-500">{field.description}</p>
+          )}
+          <input
+            type="date"
+            value={String(value)}
+            onChange={(e) => handleFieldChange(field.name, e.target.value)}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
+            required={field.required}
+          />
+        </div>
+      )
+    }
+
+    // Default: text input
+    return (
+      <div key={field.name} className="space-y-1.5">
+        <label className="block text-sm font-medium text-gray-700">
+          {field.label}
+          {field.required && <span className="text-red-500 ml-1">*</span>}
+        </label>
+        {field.description && (
+          <p className="text-xs text-gray-500">{field.description}</p>
+        )}
+        <input
+          type="text"
+          value={String(value)}
+          onChange={(e) => handleFieldChange(field.name, e.target.value)}
+          className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
+          required={field.required}
+        />
+      </div>
+    )
+  }
+
+  if (isLoading) {
+    return (
+      <div className="flex items-center justify-center py-12">
+        <svg
+          className="h-8 w-8 animate-spin text-primary-600"
+          xmlns="http://www.w3.org/2000/svg"
+          fill="none"
+          viewBox="0 0 24 24"
+        >
+          <circle
+            className="opacity-25"
+            cx="12"
+            cy="12"
+            r="10"
+            stroke="currentColor"
+            strokeWidth="4"
+          />
+          <path
+            className="opacity-75"
+            fill="currentColor"
+            d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+          />
+        </svg>
+      </div>
+    )
+  }
+
+  if (!view) {
+    return (
+      <div className="p-6">
+        <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
+          View not found
+        </div>
+      </div>
+    )
+  }
+
+  const fields = view.schema?.fields || []
+
+  return (
+    <div className="mx-auto max-w-2xl p-6">
+      <div className="mb-6">
+        <h1 className="text-2xl font-bold text-gray-900">
+          {isEdit ? 'Edit' : 'Create'} {view.name}
+        </h1>
+        <p className="mt-1 text-sm text-gray-500">
+          {isEdit ? 'Update the document details below' : 'Fill in the details to create a new document'}
+        </p>
+      </div>
+
+      {error && (
+        <div className="mb-6 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
+          {error}
+        </div>
+      )}
+
+      <form onSubmit={handleSubmit} className="space-y-6">
+        <div className="rounded-lg bg-white p-6 shadow">
+          <div className="space-y-4">
+            {fields.map(field => renderField(field))}
+          </div>
+        </div>
+
+        <div className="flex justify-end gap-3">
+          <Button type="button" variant="secondary" onClick={handleCancel} disabled={isSaving}>
+            Cancel
+          </Button>
+          <Button type="submit" isLoading={isSaving}>
+            {isEdit ? 'Save Changes' : 'Create'}
+          </Button>
+        </div>
+      </form>
+    </div>
+  )
+}

+ 5 - 20
webui/src/pages/Users.tsx

@@ -2,7 +2,6 @@
 
 import { useState, useEffect, useCallback } from 'react'
 import apiClient from '@/api/client'
-import { useWorkspace } from '@/contexts/WorkspaceContext'
 import Button from '@/components/Button'
 import Input from '@/components/Input'
 import type { User } from '@/types'
@@ -167,7 +166,6 @@ function DeleteConfirmModal({
 }
 
 function Users() {
-  const { currentWorkspace } = useWorkspace()
   const [users, setUsers] = useState<User[]>([])
   const [filteredUsers, setFilteredUsers] = useState<User[]>([])
   const [isLoading, setIsLoading] = useState(true)
@@ -181,26 +179,15 @@ function Users() {
     setIsLoading(true)
     setError(null)
     try {
-      // If workspace is selected, filter by workspace, otherwise get all users
-      const endpoint = currentWorkspace ? `/workspaces/${currentWorkspace.id}/members` : '/users'
-      const response = await apiClient.get<{
-        users?: User[]
-        members?: Array<{ user_id: string; user?: User }>
-      }>(endpoint)
-
-      if (currentWorkspace && response.members) {
-        // Extract users from members response
-        const memberUsers = response.members.filter((m) => m.user).map((m) => m.user as User)
-        setUsers(memberUsers)
-      } else {
-        setUsers(response.users || [])
-      }
+      // Always fetch all users - this is a system-level user management page
+      const response = await apiClient.get<{ users?: User[] }>('/users')
+      setUsers(response.users || [])
     } catch (err) {
       setError(err instanceof Error ? err.message : 'Failed to fetch users')
     } finally {
       setIsLoading(false)
     }
-  }, [currentWorkspace])
+  }, [])
 
   useEffect(() => {
     fetchUsers()
@@ -234,9 +221,7 @@ function Users() {
       <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
         <div>
           <h1 className="text-2xl font-bold text-gray-900">Users</h1>
-          <p className="mt-1 text-gray-600">
-            {currentWorkspace ? `Manage users in ${currentWorkspace.name}` : 'Manage all users'}
-          </p>
+          <p className="mt-1 text-gray-600">Manage all users in the system</p>
         </div>
         <Button onClick={() => setShowCreateModal(true)}>
           <svg className="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">

+ 295 - 9
webui/src/pages/Views.tsx

@@ -20,16 +20,52 @@ const FIELD_TYPES = [
   { value: 'boolean', label: 'Boolean', icon: '✓' },
   { value: 'select', label: 'Select', icon: '▼' },
   { value: 'reference', label: 'Reference', icon: '→' },
+  { value: 'color', label: 'Color', icon: '🎨' },
 ] as const
 
-const WIDGET_TYPES = [
-  { value: '', label: 'Auto' },
-  { value: 'textarea', label: 'Text Area' },
-  { value: 'select', label: 'Dropdown' },
-  { value: 'radio', label: 'Radio Buttons' },
-  { value: 'checkbox', label: 'Checkbox' },
-  { value: 'color', label: 'Color Picker' },
-] as const
+// Widget types per field type - only show relevant widgets
+const WIDGET_TYPES_BY_FIELD: Record<string, Array<{ value: string; label: string }>> = {
+  text: [
+    { value: '', label: 'Auto (Single line)' },
+    { value: 'textarea', label: 'Text Area (Multi-line)' },
+  ],
+  number: [
+    { value: '', label: 'Auto (Number input)' },
+    { value: 'slider', label: 'Slider' },
+  ],
+  email: [
+    { value: '', label: 'Auto (Email input)' },
+  ],
+  url: [
+    { value: '', label: 'Auto (URL input)' },
+  ],
+  date: [
+    { value: '', label: 'Auto (Date picker)' },
+  ],
+  datetime: [
+    { value: '', label: 'Auto (Date & Time picker)' },
+  ],
+  boolean: [
+    { value: '', label: 'Auto (Checkbox)' },
+    { value: 'switch', label: 'Toggle Switch' },
+  ],
+  select: [
+    { value: '', label: 'Auto (Dropdown)' },
+    { value: 'radio', label: 'Radio Buttons' },
+  ],
+  reference: [
+    { value: '', label: 'Auto (Dropdown)' },
+    { value: 'autocomplete', label: 'Autocomplete' },
+  ],
+  color: [
+    { value: '', label: 'Auto (Color picker)' },
+  ],
+}
+
+// Get widgets for a field type
+const getWidgetsForType = (fieldType: string) => {
+  return WIDGET_TYPES_BY_FIELD[fieldType] || [{ value: '', label: 'Auto' }]
+}
 
 interface SchemaField {
   _id: string // Client-side ID for React keys
@@ -228,7 +264,7 @@ function FieldEditor({
               onChange={(e) => onUpdate({ ...field, widget: e.target.value })}
               className="w-full rounded-lg border border-gray-300 px-3 py-2"
             >
-              {WIDGET_TYPES.map((w) => (
+              {getWidgetsForType(field.type).map((w) => (
                 <option key={w.value} value={w.value}>
                   {w.label}
                 </option>
@@ -337,6 +373,28 @@ function ViewModal({
   const [isLoading, setIsLoading] = useState(false)
   const [error, setError] = useState<string | null>(null)
 
+  // Create settings state
+  const [showCreateButton, setShowCreateButton] = useState<boolean>(
+    view?.settings?.show_create_button !== false
+  )
+  const [quickCreateMode, setQuickCreateMode] = useState<string>(
+    (view?.settings?.quick_create_mode as string) || 'modal'
+  )
+  const [quickCreateFields, setQuickCreateFields] = useState<string[]>(
+    (view?.settings?.quick_create_fields as string[]) || []
+  )
+
+  // Edit settings state
+  const [showEditButton, setShowEditButton] = useState<boolean>(
+    view?.settings?.show_edit_button !== false
+  )
+  const [quickEditMode, setQuickEditMode] = useState<string>(
+    (view?.settings?.quick_edit_mode as string) || 'modal'
+  )
+  const [quickEditFields, setQuickEditFields] = useState<string[]>(
+    (view?.settings?.quick_edit_fields as string[]) || []
+  )
+
   // Local state for field name editing to avoid re-renders
   const [editingFieldId, setEditingFieldId] = useState<string | null>(null)
   const [editingFieldName, setEditingFieldName] = useState('')
@@ -454,6 +512,18 @@ function ViewModal({
         schema: {
           fields: schemaFields,
         },
+        settings: {
+          show_create_button: showCreateButton,
+          quick_create_mode: quickCreateMode,
+          quick_create_fields: quickCreateFields.filter(f =>
+            schemaFields.some(sf => sf.name === f)
+          ),
+          show_edit_button: showEditButton,
+          quick_edit_mode: quickEditMode,
+          quick_edit_fields: quickEditFields.filter(f =>
+            schemaFields.some(sf => sf.name === f)
+          ),
+        },
       }
 
       if (isEditing && view) {
@@ -611,6 +681,222 @@ function ViewModal({
               )}
             </div>
 
+            {/* Settings Section */}
+            <div className="mt-6 border-t pt-6">
+              <h3 className="mb-4 text-lg font-medium text-gray-900">Display Settings</h3>
+
+              <div className="space-y-4">
+                {/* Show create button toggle */}
+                <label className="flex items-center gap-3">
+                  <input
+                    type="checkbox"
+                    checked={showCreateButton}
+                    onChange={(e) => setShowCreateButton(e.target.checked)}
+                    className="h-4 w-4 rounded border-gray-300"
+                  />
+                  <div>
+                    <span className="font-medium text-gray-900">Show create button</span>
+                    <p className="text-sm text-gray-500">
+                      Allow users to add new items directly from this view
+                    </p>
+                  </div>
+                </label>
+
+                {/* Quick create mode selector - only show when create button is enabled */}
+                {showCreateButton && (
+                  <div>
+                    <label className="mb-2 block text-sm font-medium text-gray-700">
+                      Create form style
+                    </label>
+                    <div className="grid gap-3 sm:grid-cols-3">
+                      <label className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${quickCreateMode === 'modal' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'}`}>
+                        <input
+                          type="radio"
+                          name="quickCreateMode"
+                          value="modal"
+                          checked={quickCreateMode === 'modal'}
+                          onChange={(e) => setQuickCreateMode(e.target.value)}
+                          className="mt-0.5 h-4 w-4"
+                        />
+                        <div>
+                          <span className="font-medium text-gray-900">Modal</span>
+                          <p className="text-xs text-gray-500">Popup dialog</p>
+                        </div>
+                      </label>
+                      <label className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${quickCreateMode === 'inline' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'}`}>
+                        <input
+                          type="radio"
+                          name="quickCreateMode"
+                          value="inline"
+                          checked={quickCreateMode === 'inline'}
+                          onChange={(e) => setQuickCreateMode(e.target.value)}
+                          className="mt-0.5 h-4 w-4"
+                        />
+                        <div>
+                          <span className="font-medium text-gray-900">Inline</span>
+                          <p className="text-xs text-gray-500">Form on page</p>
+                        </div>
+                      </label>
+                      <label className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${quickCreateMode === 'page' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'}`}>
+                        <input
+                          type="radio"
+                          name="quickCreateMode"
+                          value="page"
+                          checked={quickCreateMode === 'page'}
+                          onChange={(e) => setQuickCreateMode(e.target.value)}
+                          className="mt-0.5 h-4 w-4"
+                        />
+                        <div>
+                          <span className="font-medium text-gray-900">Full page</span>
+                          <p className="text-xs text-gray-500">Dedicated page</p>
+                        </div>
+                      </label>
+                    </div>
+                  </div>
+                )}
+
+                {/* Quick create fields - only show when create button is enabled and mode is not page */}
+                {showCreateButton && quickCreateMode !== 'page' && sortedFields.length > 0 && (
+                  <div>
+                    <label className="mb-2 block text-sm font-medium text-gray-700">
+                      Quick create fields
+                    </label>
+                    <p className="mb-3 text-sm text-gray-500">
+                      Select which fields to show in the {quickCreateMode === 'inline' ? 'inline form' : 'modal'}. Leave empty to show all fields.
+                    </p>
+                    <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
+                      {sortedFields.map((field) => (
+                        <label key={field._id} className="flex items-center gap-2">
+                          <input
+                            type="checkbox"
+                            checked={quickCreateFields.includes(field.name)}
+                            onChange={(e) => {
+                              if (e.target.checked) {
+                                setQuickCreateFields([...quickCreateFields, field.name])
+                              } else {
+                                setQuickCreateFields(quickCreateFields.filter(f => f !== field.name))
+                              }
+                            }}
+                            className="h-4 w-4 rounded border-gray-300"
+                          />
+                          <span className="text-sm text-gray-700">
+                            {field.label || field.name}
+                          </span>
+                        </label>
+                      ))}
+                    </div>
+                  </div>
+                )}
+
+                {/* Divider between Create and Edit settings */}
+                <div className="border-t border-gray-200 pt-4">
+                  <h4 className="mb-4 font-medium text-gray-900">Edit Settings</h4>
+                </div>
+
+                {/* Show edit button toggle */}
+                <label className="flex items-center gap-3">
+                  <input
+                    type="checkbox"
+                    checked={showEditButton}
+                    onChange={(e) => setShowEditButton(e.target.checked)}
+                    className="h-4 w-4 rounded border-gray-300"
+                  />
+                  <div>
+                    <span className="font-medium text-gray-900">Show edit button</span>
+                    <p className="text-sm text-gray-500">
+                      Allow users to edit items from this view
+                    </p>
+                  </div>
+                </label>
+
+                {/* Quick edit mode selector - only show when edit button is enabled */}
+                {showEditButton && (
+                  <div>
+                    <label className="mb-2 block text-sm font-medium text-gray-700">
+                      Edit form style
+                    </label>
+                    <div className="grid gap-3 sm:grid-cols-3">
+                      <label className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${quickEditMode === 'modal' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'}`}>
+                        <input
+                          type="radio"
+                          name="quickEditMode"
+                          value="modal"
+                          checked={quickEditMode === 'modal'}
+                          onChange={(e) => setQuickEditMode(e.target.value)}
+                          className="mt-0.5 h-4 w-4"
+                        />
+                        <div>
+                          <span className="font-medium text-gray-900">Modal</span>
+                          <p className="text-xs text-gray-500">Popup dialog</p>
+                        </div>
+                      </label>
+                      <label className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${quickEditMode === 'inline' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'}`}>
+                        <input
+                          type="radio"
+                          name="quickEditMode"
+                          value="inline"
+                          checked={quickEditMode === 'inline'}
+                          onChange={(e) => setQuickEditMode(e.target.value)}
+                          className="mt-0.5 h-4 w-4"
+                        />
+                        <div>
+                          <span className="font-medium text-gray-900">Inline</span>
+                          <p className="text-xs text-gray-500">Edit in place</p>
+                        </div>
+                      </label>
+                      <label className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${quickEditMode === 'page' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'}`}>
+                        <input
+                          type="radio"
+                          name="quickEditMode"
+                          value="page"
+                          checked={quickEditMode === 'page'}
+                          onChange={(e) => setQuickEditMode(e.target.value)}
+                          className="mt-0.5 h-4 w-4"
+                        />
+                        <div>
+                          <span className="font-medium text-gray-900">Full page</span>
+                          <p className="text-xs text-gray-500">Dedicated page</p>
+                        </div>
+                      </label>
+                    </div>
+                  </div>
+                )}
+
+                {/* Quick edit fields - only show when edit button is enabled and mode is not page */}
+                {showEditButton && quickEditMode !== 'page' && sortedFields.length > 0 && (
+                  <div>
+                    <label className="mb-2 block text-sm font-medium text-gray-700">
+                      Quick edit fields
+                    </label>
+                    <p className="mb-3 text-sm text-gray-500">
+                      Select which fields to show in the {quickEditMode === 'inline' ? 'inline form' : 'modal'}. Leave empty to show all fields.
+                    </p>
+                    <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
+                      {sortedFields.map((field) => (
+                        <label key={field._id} className="flex items-center gap-2">
+                          <input
+                            type="checkbox"
+                            checked={quickEditFields.includes(field.name)}
+                            onChange={(e) => {
+                              if (e.target.checked) {
+                                setQuickEditFields([...quickEditFields, field.name])
+                              } else {
+                                setQuickEditFields(quickEditFields.filter(f => f !== field.name))
+                              }
+                            }}
+                            className="h-4 w-4 rounded border-gray-300"
+                          />
+                          <span className="text-sm text-gray-700">
+                            {field.label || field.name}
+                          </span>
+                        </label>
+                      ))}
+                    </div>
+                  </div>
+                )}
+              </div>
+            </div>
+
             {/* Permissions Section - only show when editing or when collection is selected */}
             {collectionName && (
               <div className="mt-6 border-t pt-6">