| @@ -18,6 +18,7 @@ core/ Framework internals — do not modify | |||
| mvc.asp | |||
| router.wsc | |||
| lib.*.asp Core libraries (see below) | |||
| helpers.asp Global utility functions (always available) | |||
| app/ | |||
| controllers/ Controller classes (one per feature area) | |||
| @@ -41,19 +42,19 @@ scripts/ VBScript code generators | |||
| | File | Purpose | | |||
| |---|---| | |||
| | `lib.Keycloak.asp` | OpenID Connect / Keycloak auth helper | | |||
| | `lib.Routes.asp` | Route registration and URL helpers | | |||
| | `lib.Routes.asp` | URL generation helpers (`Routes()` singleton) | | |||
| | `lib.ControllerRegistry.asp` | Controller whitelist (security) | | |||
| | `lib.DAL.asp` | Database access layer | | |||
| | `lib.DAL.asp` | Database access layer (`DAL` singleton) | | |||
| | `lib.Data.asp` | Data helpers | | |||
| | `lib.Collections.asp` | Dictionary/list helpers | | |||
| | `lib.Collections.asp` | `LinkedList_Class` and other collection types | | |||
| | `lib.Enumerable.asp` | Collection iteration helpers | | |||
| | `lib.Validations.asp` | Input validation | | |||
| | `lib.Flash.asp` | Flash message helpers | | |||
| | `lib.FormCache.asp` | Form value re-population | | |||
| | `lib.HTML.asp` | HTML rendering helpers | | |||
| | `lib.HTML.Security.asp` | XSS escaping (`H()` function) | | |||
| | `lib.HTML.Security.asp` | XSS escaping — `H()` function | | |||
| | `lib.Strings.asp` | String utilities | | |||
| | `lib.Automapper.asp` | Object mapping helpers | | |||
| | `lib.Automapper.asp` | `Automapper.AutoMap(rs, "POBO_TableName")` | | |||
| | `lib.ErrorHandler.asp` | Error handling | | |||
| | `lib.Migrations.asp` | Migration runner | | |||
| | `lib.json.asp` | JSON parsing/serialization | | |||
| @@ -64,40 +65,340 @@ scripts/ VBScript code generators | |||
| --- | |||
| ## Routes | |||
| ## Global helper functions (core/helpers.asp) | |||
| These are always available — never re-implement them. | |||
| | Function | Signature | Purpose | | |||
| |---|---|---| | |||
| | `H` | `H(s)` | XSS-safe HTML encode — use on all user data rendered to HTML | | |||
| | `GenerateSlug` | `GenerateSlug(title)` | Converts a title to a safe URL slug (lowercase, hyphens, alphanumeric only) | | |||
| | `GetRawJsonFromRequest` | `GetRawJsonFromRequest()` | Reads raw JSON body from an AJAX POST request | | |||
| | `GetAppSetting` | `GetAppSetting(key)` | Reads a value from `web.config` appSettings (cached in `Application`) | | |||
| | `IIf` | `IIf(condition, trueVal, falseVal)` | Inline conditional | | |||
| | `TrimQueryParams` | `TrimQueryParams(path)` | Strips query string from a URL path | | |||
| | `Destroy` | `Destroy(obj)` | Safely closes and sets an object to Nothing | | |||
| | `QuoteValue` | `QuoteValue(val)` | Quotes a value for SQL (prefer parameterized queries via DAL instead) | | |||
| | `FormatDateForSql` | `FormatDateForSql(date)` | Formats a VBScript date as a SQL Server datetime string | | |||
| | `Active` | `Active(controllerName)` | Returns `"active"` if the current request maps to that controller | | |||
| | `SurroundString` | `SurroundString(val)` | Wraps strings in double-quotes (used internally by dispatcher) | | |||
| | `GetDynamicProperty` | `GetDynamicProperty(obj, propName)` | Reads a property by name from any VBScript object | | |||
| | `RenderObjectsAsTable` | `RenderObjectsAsTable(arr, useTabulator)` | Generates an HTML table from an array of POBOs | | |||
| | `RenderFormFromObject` | `RenderFormFromObject(obj)` | Generates an HTML form from a POBO | | |||
| Defined in [public/Default.asp](public/Default.asp): | |||
| --- | |||
| ## Router — URL parameters | |||
| Routes are registered in [public/Default.asp](public/Default.asp) and support `:param` segments. | |||
| **Registering routes:** | |||
| ```vbscript | |||
| ' Static route | |||
| router.AddRoute "GET", "/boards", "BoardsController", "Index" | |||
| router.AddRoute "POST", "/boards", "BoardsController", "Store" | |||
| | Method | Path | Controller | Action | | |||
| |---|---|---|---| | |||
| | GET | `/` | HomeController | Index | | |||
| | GET | `/home` | HomeController | Index | | |||
| | GET | `/auth/login` | AuthController | Login | | |||
| | GET | `/auth/callback` | AuthController | Callback | | |||
| | GET | `/auth/logout` | AuthController | Logout | | |||
| | GET | `/404` | ErrorController | NotFound | | |||
| ' Route with one URL parameter | |||
| router.AddRoute "GET", "/board/:slug", "BoardsController", "Show" | |||
| router.AddRoute "POST", "/board/:slug", "BoardsController", "Update" | |||
| ' Route with one URL parameter and a sub-action | |||
| router.AddRoute "POST", "/cards/:id/move", "CardsController", "Move" | |||
| router.AddRoute "POST", "/cards/:id/delete", "CardsController", "Destroy" | |||
| ``` | |||
| **Controller action receives parameters as arguments in order:** | |||
| ```vbscript | |||
| ' Matches /board/:slug | |||
| Public Sub Show(slug) | |||
| ' slug is the extracted URL segment | |||
| End Sub | |||
| ' Matches /cards/:id/move | |||
| Public Sub Move(id) | |||
| ' id is the extracted URL segment | |||
| End Sub | |||
| ``` | |||
| The router does an **exact match first**, then falls back to `:param` pattern matching. If nothing matches it returns `ErrorController#NotFound`. | |||
| **URL generation from controllers/views:** | |||
| ```vbscript | |||
| ' /boards | |||
| Routes.UrlTo "Boards", "Index", Empty | |||
| ' /board/my-board (route param appended as positional segment) | |||
| Routes.UrlToWithParams "Boards", "Show", Array("my-board"), Empty | |||
| ' With query string | |||
| Routes.UrlTo "Boards", "Index", Array("page", 2) | |||
| ``` | |||
| All requests are rewritten through `Default.asp` by the IIS URL Rewrite rule. Static assets (`css/`, `js/`, `images/`, `favicon.ico`) bypass the rewrite. | |||
| --- | |||
| ## Configuration (public/web.config appSettings) | |||
| ## POBO pattern (app/models/) | |||
| | Key | Description | | |||
| |---|---| | |||
| | `ConnectionString` | ACE OLEDB path to `webdata.accdb` | | |||
| | `Environment` | `Development`, `Staging`, or `Production` | | |||
| | `KeycloakBaseUrl` | Keycloak server base URL (no `/realms/...`) | | |||
| | `KeycloakRealm` | Keycloak realm name | | |||
| | `KeycloakClientId` | Client ID | | |||
| | `KeycloakClientSecret` | Client secret — never commit real values | | |||
| | `KeycloakRedirectUri` | Absolute callback URL, e.g. `http://localhost:8080/auth/callback` | | |||
| | `KeycloakLogoutRedirectUri` | Post-logout redirect URL | | |||
| | `KeycloakScope` | OIDC scopes (default: `openid profile email`) | | |||
| | `KeycloakEnableLogging` | `true`/`false` — diagnostic log for auth failures | | |||
| | `KeycloakLogPath` | Path to Keycloak log file | | |||
| | `EnableErrorLogging` | `true`/`false` | | |||
| | `ErrorLogPath` | Path to error log file | | |||
| Plain-Old Business Object — one class per table. Generated by `scripts/GenerateRepo.vbs`. | |||
| ```vbscript | |||
| <% | |||
| Class POBO_boards | |||
| Public Properties ' array of all column names | |||
| Private p_id | |||
| Private p_name | |||
| Private p_slug | |||
| Private p_created_at | |||
| Private p_created_by | |||
| Private p_updated_at | |||
| Private p_updated_by | |||
| Private Sub Class_Initialize() | |||
| p_id = 0 | |||
| p_name = "" | |||
| p_slug = "" | |||
| p_created_at = #1/1/1970# | |||
| p_created_by = "" | |||
| p_updated_at = #1/1/1970# | |||
| p_updated_by = "" | |||
| Properties = Array("id","name","slug","created_at","created_by","updated_at","updated_by") | |||
| End Sub | |||
| Public Property Get PrimaryKey() : PrimaryKey = "id" : End Property | |||
| Public Property Get TableName() : TableName = "boards" : End Property | |||
| Public Property Get id() : id = p_id : End Property | |||
| Public Property Let id(v) : p_id = CDbl(v) : End Property | |||
| Public Property Get name() : name = p_name : End Property | |||
| Public Property Let name(v) : p_name = CStr(v) : End Property | |||
| ' ... remaining Get/Let pairs follow same pattern | |||
| End Class | |||
| %> | |||
| ``` | |||
| Key rules: | |||
| - Private backing fields use the `p` prefix: `p_id`, `p_name`, etc. | |||
| - `Properties` array lists all column names — required by `Automapper` and `RenderObjectsAsTable`. | |||
| - `PrimaryKey` and `TableName` must match the actual table. | |||
| --- | |||
| ## Repository pattern (app/repositories/) | |||
| Generated by `scripts/GenerateRepo.vbs`. Uses the `DAL` singleton and `Automapper`. | |||
| ```vbscript | |||
| <% | |||
| Class boards_Repository_Class | |||
| ' Single record by PK | |||
| Public Function FindByID(id) | |||
| Dim sql : sql = "SELECT [id],[name],[slug],[created_at],[created_by],[updated_at],[updated_by] FROM [boards] WHERE [id] = ?" | |||
| Dim rs : Set rs = DAL.Query(sql, Array(id)) | |||
| If rs.EOF Then | |||
| Err.Raise 1, "boards_Repository_Class", "boards record not found with id = " & id | |||
| Else | |||
| Set FindByID = Automapper.AutoMap(rs, "POBO_boards") | |||
| End If | |||
| Destroy rs | |||
| End Function | |||
| ' All records, optional order | |||
| Public Function GetAll(orderBy) | |||
| Set GetAll = Find(Empty, orderBy) | |||
| End Function | |||
| ' Filtered list — where_kvarray is a flat key/value array: Array("col", val, "col2", val2) | |||
| Public Function Find(where_kvarray, order_string_or_array) | |||
| Dim sql : sql = "SELECT ... FROM [boards]" | |||
| Dim where_keys, where_values, i | |||
| If Not IsEmpty(where_kvarray) Then | |||
| KVUnzip where_kvarray, where_keys, where_values | |||
| ' appends WHERE clause for each key | |||
| End If | |||
| sql = sql & BuildOrderBy(order_string_or_array, "[id]") | |||
| Dim rs : Set rs = DAL.Query(sql, where_values) | |||
| Dim list : Set list = New LinkedList_Class | |||
| Do Until rs.EOF | |||
| list.Push Automapper.AutoMap(rs, "POBO_boards") | |||
| rs.MoveNext | |||
| Loop | |||
| Set Find = list | |||
| Destroy rs | |||
| End Function | |||
| ' Insert — sets model.id to the new identity value | |||
| Public Sub AddNew(ByRef model) | |||
| Dim sql : sql = "INSERT INTO [boards] ([name],[slug],...) VALUES (?,?,...)" | |||
| DAL.Execute sql, Array(model.name, model.slug, ...) | |||
| Dim rsId : Set rsId = DAL.Query("SELECT @@IDENTITY AS NewID", Empty) | |||
| If Not rsId.EOF Then | |||
| If Not IsNull(rsId(0)) Then model.id = rsId(0) | |||
| End If | |||
| Destroy rsId | |||
| End Sub | |||
| ' Update — all columns except PK, PK appended last for WHERE | |||
| Public Sub Update(model) | |||
| Dim sql : sql = "UPDATE [boards] SET [name]=?,[slug]=?,... WHERE [id]=?" | |||
| DAL.Execute sql, Array(model.name, model.slug, ..., model.id) | |||
| End Sub | |||
| ' Delete by PK | |||
| Public Sub Delete(id) | |||
| DAL.Execute "DELETE FROM [boards] WHERE [id]=?", Array(id) | |||
| End Sub | |||
| End Class | |||
| Dim boards_Repository__Singleton | |||
| Function boards_Repository() | |||
| If IsEmpty(boards_Repository__Singleton) Then | |||
| Set boards_Repository__Singleton = New boards_Repository_Class | |||
| End If | |||
| Set boards_Repository = boards_Repository__Singleton | |||
| End Function | |||
| %> | |||
| ``` | |||
| --- | |||
| ## Controller pattern (app/controllers/) | |||
| Generated by `scripts/generateController.vbs`. Singleton per request. | |||
| ```vbscript | |||
| <% | |||
| Class BoardsController_Class | |||
| Private m_useLayout | |||
| Private m_title | |||
| Private Sub Class_Initialize() | |||
| m_useLayout = True | |||
| m_title = "Boards" | |||
| End Sub | |||
| Public Property Get useLayout() : useLayout = m_useLayout : End Property | |||
| Public Property Let useLayout(v) : m_useLayout = v : End Property | |||
| Public Property Get Title() : Title = m_title : End Property | |||
| Public Property Let Title(v) : m_title = v : End Property | |||
| ' GET /boards | |||
| Public Sub Index() | |||
| If Not KeycloakRequireLogin("") Then Exit Sub | |||
| ' ... load data, render view | |||
| End Sub | |||
| ' GET /board/:slug — slug comes from the URL parameter | |||
| Public Sub Show(slug) | |||
| If Not KeycloakRequireLogin("") Then Exit Sub | |||
| ' ... load board by slug, render view | |||
| End Sub | |||
| ' POST /boards — reads from Request.Form | |||
| Public Sub Store() | |||
| If Not KeycloakRequireLogin("") Then Exit Sub | |||
| ' ... validate, save, redirect | |||
| End Sub | |||
| ' JSON endpoint — set useLayout = False, write JSON response | |||
| Public Sub Move(id) | |||
| m_useLayout = False | |||
| Response.ContentType = "application/json" | |||
| If Not KeycloakIsLoggedIn() Then | |||
| Response.Write "{""ok"":false,""error"":""Unauthorized""}" | |||
| Exit Sub | |||
| End If | |||
| ' ... process, write JSON | |||
| End Sub | |||
| End Class | |||
| Dim BoardsController_Class__Singleton | |||
| Function BoardsController() | |||
| If IsEmpty(BoardsController_Class__Singleton) Then | |||
| Set BoardsController_Class__Singleton = New BoardsController_Class | |||
| End If | |||
| Set BoardsController = BoardsController_Class__Singleton | |||
| End Function | |||
| %> | |||
| ``` | |||
| **JSON / AJAX actions** must set `m_useLayout = False` and `Response.ContentType = "application/json"`. | |||
| **Reading a JSON request body** (sent by `fetch` / XHR): | |||
| ```vbscript | |||
| Dim rawJson : rawJson = GetRawJsonFromRequest() | |||
| ' then parse with lib.json.asp | |||
| ``` | |||
| --- | |||
| ## Audit columns | |||
| **Every table must include these four audit columns:** | |||
| | Column | Type | Populated by | | |||
| |---|---|---| | |||
| | `created_at` | DateTime | Set once on insert: `Now()` | | |||
| | `created_by` | Text(255) | Set once on insert: `preferred_username` from Keycloak | | |||
| | `updated_at` | DateTime | Updated on every save: `Now()` | | |||
| | `updated_by` | Text(255) | Updated on every save: `preferred_username` from Keycloak | | |||
| **Getting the current username in a controller action:** | |||
| ```vbscript | |||
| Dim currentUser : Set currentUser = KeycloakCurrentUser() | |||
| Dim currentUsername : currentUsername = "" | |||
| If Not currentUser Is Nothing Then | |||
| currentUsername = currentUser.Item("preferred_username") | |||
| End If | |||
| ``` | |||
| **Setting audit fields before insert:** | |||
| ```vbscript | |||
| model.created_at = Now() | |||
| model.created_by = currentUsername | |||
| model.updated_at = Now() | |||
| model.updated_by = currentUsername | |||
| repo.AddNew model | |||
| ``` | |||
| **Setting audit fields before update:** | |||
| ```vbscript | |||
| model.updated_at = Now() | |||
| model.updated_by = currentUsername | |||
| repo.Update model | |||
| ``` | |||
| `created_at` and `created_by` are **never changed** after the initial insert. | |||
| --- | |||
| ## Slug generation | |||
| `GenerateSlug(title)` is already in `core/helpers.asp`. Always use it — never write your own slug logic. | |||
| ```vbscript | |||
| Dim slug : slug = GenerateSlug(Request.Form("name")) | |||
| ' "My Board & Things!" → "my-board-and-things" | |||
| ``` | |||
| After generating, check uniqueness against the database before saving. If the slug already exists, append a suffix (e.g., `-2`, `-3`). | |||
| --- | |||
| ## Wiring up a new controller — checklist | |||
| 1. Generate: `cscript //nologo scripts\generateController.vbs MyController "Index;Show(slug);Store"` | |||
| 2. Move generated file to `app/controllers/` | |||
| 3. Register in [core/lib.ControllerRegistry.asp](core/lib.ControllerRegistry.asp): `RegisterController "mycontroller"` | |||
| 4. Include in [app/controllers/autoload_controllers.asp](app/controllers/autoload_controllers.asp): `<!--#include file="MyController.asp"-->` | |||
| 5. Add routes in [public/Default.asp](public/Default.asp) | |||
| 6. Create views in `app/views/MyController/` | |||
| --- | |||
| @@ -108,50 +409,42 @@ The helper in `core/lib.Keycloak.asp` implements the OpenID Connect authorizatio | |||
| **Key functions:** | |||
| ```vbscript | |||
| KeycloakLogin() ' Redirect to Keycloak | |||
| KeycloakHandleCallback() ' Exchange code, store tokens — returns True on success | |||
| KeycloakIsLoggedIn() ' True if access token is in Session | |||
| KeycloakCurrentUser() ' Returns userinfo dictionary | |||
| KeycloakAccessToken() ' Raw access token string | |||
| KeycloakRequireLogin("") ' Gate full-page actions — redirects if not logged in; returns False to signal early exit | |||
| KeycloakIsLoggedIn() ' Use this for JSON/AJAX actions instead of RequireLogin | |||
| KeycloakCurrentUser() ' Returns userinfo Dictionary — keys include preferred_username, email, name, sub | |||
| KeycloakLogin() ' Redirect to Keycloak | |||
| KeycloakHandleCallback() ' Exchange code, store tokens — returns True on success | |||
| KeycloakAccessToken() ' Raw access token string | |||
| KeycloakRefreshToken() | |||
| KeycloakIdToken() | |||
| KeycloakTokenClaims(token) ' Decode JWT payload into dictionary | |||
| KeycloakRequireLogin(returnToPath) ' Gate a page — redirects if not logged in | |||
| KeycloakConsumePostLoginRedirectPath("/") ' Get and clear stored return path | |||
| KeycloakHasRealmRole("admin") ' Role check against ID token | |||
| KeycloakTokenClaims(token) ' Decode JWT payload into dictionary | |||
| KeycloakConsumePostLoginRedirectPath("/") ' Get and clear stored return path | |||
| KeycloakHasRealmRole("admin") ' Role check against ID token | |||
| KeycloakHasClientRole(clientId, role) | |||
| KeycloakLogout("") ' Clear session and redirect to Keycloak logout | |||
| KeycloakLogout("") ' Clear session and redirect to Keycloak logout | |||
| ``` | |||
| Session keys use the `Keycloak_` prefix. Tokens are stored in `Session`, not cookies. | |||
| --- | |||
| ## Adding a new feature — step by step | |||
| ### 1. Migration | |||
| ```bat | |||
| cscript //nologo scripts\generateMigration.vbs create_my_table | |||
| cscript //nologo scripts\runMigrations.vbs | |||
| ``` | |||
| ### 2. Model and repository | |||
| ```bat | |||
| cscript //nologo scripts\GenerateRepo.vbs /table:my_table /pk:id | |||
| ``` | |||
| Move generated files to `app/models/` and `app/repositories/`. | |||
| ### 3. Controller | |||
| ```bat | |||
| cscript //nologo scripts\generateController.vbs MyController "Index;Show(id);Create;Store" | |||
| ``` | |||
| Move generated file to `app/controllers/`. | |||
| ## Configuration (public/web.config appSettings) | |||
| ### 4. Wire it up | |||
| 1. Register in [core/lib.ControllerRegistry.asp](core/lib.ControllerRegistry.asp): `RegisterController "mycontroller"` | |||
| 2. Include in [app/controllers/autoload_controllers.asp](app/controllers/autoload_controllers.asp) | |||
| 3. Add routes in [public/Default.asp](public/Default.asp) | |||
| 4. Create views in `app/views/MyController/` | |||
| | Key | Description | | |||
| |---|---| | |||
| | `ConnectionString` | ACE OLEDB path to `webdata.accdb` | | |||
| | `Environment` | `Development`, `Staging`, or `Production` | | |||
| | `KeycloakBaseUrl` | Keycloak server base URL (no `/realms/...`) | | |||
| | `KeycloakRealm` | Keycloak realm name | | |||
| | `KeycloakClientId` | Client ID | | |||
| | `KeycloakClientSecret` | Client secret — never commit real values | | |||
| | `KeycloakRedirectUri` | Absolute callback URL | | |||
| | `KeycloakLogoutRedirectUri` | Post-logout redirect URL | | |||
| | `KeycloakScope` | OIDC scopes (default: `openid profile email`) | | |||
| | `KeycloakEnableLogging` | `true`/`false` | | |||
| | `KeycloakLogPath` | Path to Keycloak log file | | |||
| | `EnableErrorLogging` | `true`/`false` | | |||
| | `ErrorLogPath` | Path to error log file | | |||
| --- | |||
| @@ -164,23 +457,13 @@ Tests live in `tests/` and run as a **separate IIS application** (never through | |||
| - Manifest: `tests/test-manifest.asp` — register new test pages here manually | |||
| - Bootstrap: `tests/bootstrap.asp` — shared setup for all test pages | |||
| Test tiers: | |||
| | Folder | Use for | | |||
| |---|---| | |||
| | `tests/unit/` | Deterministic helper and registry tests | | |||
| | `tests/component/` | Controlled controller/object tests | | |||
| | `tests/integration/` | Router/dispatch smoke tests, config behavior, rendered-page capture | | |||
| After changing `tests/web.config`, sync nested configs: | |||
| ```bat | |||
| cscript //nologo tests\sync-webconfigs.vbs | |||
| ``` | |||
| Or sync and open in one step: | |||
| ```bat | |||
| tests\run-tests.cmd | |||
| ``` | |||
| After changing `tests/web.config`: `cscript //nologo tests\sync-webconfigs.vbs` | |||
| --- | |||
| @@ -194,6 +477,75 @@ tests\run-tests.cmd | |||
| --- | |||
| ## LinkedList_Class — correct traversal | |||
| `LinkedList_Class` (from `core/lib.Collections.asp`) does **not** have public `.First` or `.Next` properties — `m_first` and `m_last` are private fields. Calling `.First` on a list throws Error 438. | |||
| **Always use the `Iterator()` pattern:** | |||
| ```vbscript | |||
| Dim iter : Set iter = myList.Iterator() | |||
| Do While iter.HasNext() | |||
| Set item = iter.GetNext() ' returns the value directly — NOT a node wrapper | |||
| ' use item | |||
| Loop | |||
| ``` | |||
| `GetNext()` returns the value object directly, so there is no `.m_value` or `.m_next` to dereference. | |||
| **Other valid approaches:** | |||
| ```vbscript | |||
| ' Convert to array for indexed access | |||
| Dim arr : arr = myList.TO_Array() | |||
| For i = 0 To UBound(arr) | |||
| Set item = arr(i) | |||
| Next | |||
| ' Get single values at the ends | |||
| myList.Front() ' returns first value | |||
| myList.Back() ' returns last value | |||
| myList.Count ' number of elements | |||
| myList.IsEmpty() ' Boolean | |||
| ``` | |||
| Never use `.First`, `.Last`, `.m_first`, `.m_next`, or `.m_value` on a list — those are internal node fields, not a public API. | |||
| --- | |||
| ## AJAX form data — always use URLSearchParams, never FormData | |||
| Classic ASP's `Request.Form` collection **only parses `application/x-www-form-urlencoded`** POST bodies. Using `new FormData()` in JavaScript sends `multipart/form-data`, which Classic ASP silently ignores — every field comes back as an empty string. | |||
| **Always use `URLSearchParams` for fetch POST requests that the server reads via `Request.Form`:** | |||
| ```javascript | |||
| function post(url, data, cb) { | |||
| var params = new URLSearchParams(); | |||
| Object.keys(data).forEach(function (k) { params.append(k, data[k]); }); | |||
| fetch(url, { | |||
| method: 'POST', | |||
| headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |||
| body: params.toString() | |||
| }) | |||
| .then(function (r) { return r.json(); }) | |||
| .then(cb) | |||
| .catch(function (e) { console.error(url, e); }); | |||
| } | |||
| ``` | |||
| **`Request.BinaryRead` vs `Request.Form` mutual exclusion:** Once you call `Request.BinaryRead()` (which `GetRawJsonFromRequest()` does internally), `Request.Form` becomes unavailable for that same request, and vice versa. Never mix the two in the same action. Use `GetRawJsonFromRequest()` only in actions that receive a raw JSON body (e.g. `Reorder`), and use `Request.Form` only in actions that receive URL-encoded form data (e.g. `Store`, `Update`, `Move`). | |||
| --- | |||
| ## Critical Classic ASP scoping rule for views | |||
| Controller actions include view files via SSI (`<!--#include file="..."-->`). The included file's content compiles **inside** the controller action's Sub/Function scope. This means: | |||
| - **Never define `Function` or `Sub` inside a view file** — VBScript forbids nested procedure definitions. You will get `Syntax error 800a03ea`. | |||
| - All `Dim`, `ReDim`, loops, and conditionals in a view are fine — only `Function`/`Sub` declarations are forbidden. | |||
| - If a view needs a helper function, define it as a `Private Function` in the controller class, build the result in the action, and pass it as a variable the view can reference. | |||
| - Keep views to pure HTML rendering: `<%= variable %>`, `H(value)`, simple `<% If / For / Do %>` blocks, and includes of other partials. | |||
| --- | |||
| ## Things to avoid | |||
| - Do not modify files under `core/` — these are framework internals. | |||
| @@ -201,4 +553,31 @@ tests\run-tests.cmd | |||
| - Do not commit real `KeycloakClientSecret` values — inject per environment. | |||
| - Do not add test routes or test pages under `public/`. | |||
| - Do not use the production `public/` IIS app to run tests. | |||
| - Always use `H()` from `lib.HTML.Security.asp` when rendering user-supplied data to prevent XSS. | |||
| - Always use `H()` when rendering user-supplied data to prevent XSS. | |||
| - Never write your own slug generator — use `GenerateSlug()` from `helpers.asp`. | |||
| - Never use `Private Const` or `Public Const` inside a VBScript class — it causes a syntax error. Use a `Private Function` that returns the value instead. | |||
| - In Access/Jet SQL DDL, common reserved words include `COLUMNS`, `NAME`, `POSITION`, `VALUE`, `DATE`, `KEY`, `LEVEL`, `BY`. **Always use `migration.ExecuteSQL` and bracket every identifier** — both table names and column names — e.g. `CREATE TABLE [board_columns] ([id] AUTOINCREMENT PRIMARY KEY, [name] VARCHAR(255), [position] INTEGER, ...)`. Never use `migration.CreateTable` or `migration.CreateIndex`; use `migration.ExecuteSQL` for all DDL. | |||
| - Never read raw JSON bodies manually — use `GetRawJsonFromRequest()` from `helpers.asp`. | |||
| - Never use `new FormData()` in JavaScript for requests that Classic ASP reads via `Request.Form` — it sends `multipart/form-data` which Classic ASP silently ignores. Use `URLSearchParams` instead. | |||
| - Never mix `Request.BinaryRead` / `GetRawJsonFromRequest` with `Request.Form` in the same action — they are mutually exclusive in Classic ASP. | |||
| - Never call `.First`, `.Last`, `.m_first`, `.m_next`, or `.m_value` on a `LinkedList_Class` instance — those are private internals. Use `.Iterator()` / `HasNext()` / `GetNext()` or `.TO_Array()`. | |||
| - Always set all four audit columns (`created_at`, `created_by`, `updated_at`, `updated_by`) on every insert and update. | |||
| - For JSON/AJAX actions, always set `m_useLayout = False` and `Response.ContentType = "application/json"` at the top of the action. | |||
| --- | |||
| ## Recent lessons (2026-04-22) | |||
| - IIS/runtime bitness matters for Access on this machine. Use 32-bit IIS Express (`%ProgramFiles(x86)%\IIS Express\iisexpress.exe`) and keep `enable32BitAppOnWin64="true"` in the active app pool. | |||
| - Run migrations with 32-bit cscript: | |||
| - `C:\Windows\SysWOW64\cscript.exe //nologo scripts\runMigrations.vbs up` | |||
| - Standalone migration context (`scripts/runMigrations.vbs`) is not identical to IIS runtime migration context. `migration.DB.Execute/Query` may fail there; migrations should support fallback via `migration.Connection` + `ADODB.Command`. | |||
| - `Flash_Class` API is `AddError` and `Success` property assignment. `SetError` / `SetSuccess` are invalid and cause `438`. | |||
| - `KeycloakCurrentUser()` should be read defensively. Do not assume `.Exists(...)` is always available; use guarded `.Item("preferred_username")` / `.Item("email")` reads. | |||
| - Shared navbar should include direct `/boards` navigation and use `Active("boards")` for active state. | |||
| ## Additions to Things to avoid | |||
| - Never run `scripts\runMigrations.vbs` with 64-bit cscript on this machine; use `C:\Windows\SysWOW64\cscript.exe`. | |||
| - Never assume migration helper parity between IIS and standalone runner; if `migration.DB.Execute/Query` are used, provide a `migration.Connection` fallback path. | |||
| - Never call `Flash().SetError` or `Flash().SetSuccess`; use `Flash().AddError` and `flash.Success = "..."`. | |||
| @@ -0,0 +1,238 @@ | |||
| <% | |||
| Class BoardsController_Class | |||
| Private m_useLayout | |||
| Private m_title | |||
| Private Sub Class_Initialize() | |||
| m_useLayout = True | |||
| m_title = "Boards" | |||
| End Sub | |||
| Public Property Get useLayout() : useLayout = m_useLayout : End Property | |||
| Public Property Let useLayout(v) : m_useLayout = v : End Property | |||
| Public Property Get Title() : Title = m_title : End Property | |||
| Public Property Let Title(v) : m_title = v : End Property | |||
| ' GET /boards | |||
| Public Sub Index() | |||
| If Not KeycloakRequireLogin("") Then Exit Sub | |||
| Dim boards : Set boards = boards_Repository().GetAll() | |||
| %> | |||
| <!--#include file="../views/Boards/Index.asp" --> | |||
| <% | |||
| Set boards = Nothing | |||
| End Sub | |||
| ' GET /boards/create | |||
| Public Sub Create() | |||
| If Not KeycloakRequireLogin("") Then Exit Sub | |||
| m_title = "New Board" | |||
| %> | |||
| <!--#include file="../views/Boards/Create.asp" --> | |||
| <% | |||
| End Sub | |||
| ' POST /boards | |||
| Public Sub Store() | |||
| If Not KeycloakRequireLogin("") Then Exit Sub | |||
| Dim boardName, slug, currentUsername | |||
| boardName = Trim(CStr(Request.Form("name"))) | |||
| If Len(boardName) = 0 Then | |||
| Dim flashCreateErr : Set flashCreateErr = Flash() | |||
| flashCreateErr.AddError "Board name is required." | |||
| MVC.RedirectTo "Boards", "Create" | |||
| Exit Sub | |||
| End If | |||
| currentUsername = GetCurrentUsername() | |||
| slug = boards_Repository().UniqueSlug(GenerateSlug(boardName), 0) | |||
| Dim board : Set board = New POBO_boards | |||
| board.name = boardName | |||
| board.slug = slug | |||
| board.created_at = Now() | |||
| board.created_by = currentUsername | |||
| board.updated_at = Now() | |||
| board.updated_by = currentUsername | |||
| boards_Repository().AddNew board | |||
| Dim flashCreateOk : Set flashCreateOk = Flash() | |||
| flashCreateOk.Success = "Board created." | |||
| Response.Redirect "/board/" & board.slug | |||
| End Sub | |||
| ' GET /board/:slug | |||
| Public Sub Show(slug) | |||
| If Not KeycloakRequireLogin("") Then Exit Sub | |||
| m_useLayout = False | |||
| Dim board : Set board = boards_Repository().FindBySlug(slug) | |||
| If board Is Nothing Then | |||
| Response.Status = "404 Not Found" | |||
| Response.Write "Board not found." | |||
| Exit Sub | |||
| End If | |||
| m_title = board.name | |||
| Dim columns : Set columns = board_columns_Repository().FindByBoardId(board.id) | |||
| Dim lanes : Set lanes = swim_lanes_Repository().FindByBoardId(board.id) | |||
| Dim allCards : Set allCards = cards_Repository().FindByBoardId(board.id) | |||
| ' Build arrays for the view (functions cannot be defined inside a view include) | |||
| Dim colCount : colCount = columns.Count | |||
| Dim laneCount : laneCount = lanes.Count | |||
| Dim colsArr() : ReDim colsArr(IIf(colCount > 0, colCount - 1, 0)) | |||
| Dim lanesArr() : ReDim lanesArr(IIf(laneCount > 0, laneCount - 1, 0)) | |||
| Dim colIdx, laneIdx, colIter, laneIter, colItem, laneItem | |||
| colIdx = 0 | |||
| Set colIter = columns.Iterator() | |||
| Do While colIter.HasNext() | |||
| Set colsArr(colIdx) = colIter.GetNext() | |||
| colIdx = colIdx + 1 | |||
| Loop | |||
| laneIdx = 0 | |||
| Set laneIter = lanes.Iterator() | |||
| Do While laneIter.HasNext() | |||
| Set lanesArr(laneIdx) = laneIter.GetNext() | |||
| laneIdx = laneIdx + 1 | |||
| Loop | |||
| ' Serialise cards to JSON | |||
| Dim cardsJson : cardsJson = "[" | |||
| Dim firstCard : firstCard = True | |||
| Dim cardIter, cardItem | |||
| Set cardIter = allCards.Iterator() | |||
| Do While cardIter.HasNext() | |||
| Set cardItem = cardIter.GetNext() | |||
| If Not firstCard Then cardsJson = cardsJson & "," | |||
| cardsJson = cardsJson & "{""id"":" & cardItem.id & "," & _ | |||
| """column_id"":" & cardItem.column_id & "," & _ | |||
| """swim_lane_id"":" & cardItem.swim_lane_id & "," & _ | |||
| """job_number"":" & JsonStr(cardItem.job_number) & "," & _ | |||
| """job_name"":" & JsonStr(cardItem.job_name) & "," & _ | |||
| """position"":" & cardItem.position & "}" | |||
| firstCard = False | |||
| Loop | |||
| cardsJson = cardsJson & "]" | |||
| %> | |||
| <!--#include file="../views/Boards/Show.asp" --> | |||
| <% | |||
| Set board = Nothing | |||
| Set columns = Nothing | |||
| Set lanes = Nothing | |||
| Set allCards = Nothing | |||
| End Sub | |||
| ' GET /board/:slug/edit | |||
| Public Sub Edit(slug) | |||
| If Not KeycloakRequireLogin("") Then Exit Sub | |||
| m_title = "Edit Board" | |||
| Dim board : Set board = boards_Repository().FindBySlug(slug) | |||
| If board Is Nothing Then | |||
| Response.Status = "404 Not Found" | |||
| Response.Write "Board not found." | |||
| Exit Sub | |||
| End If | |||
| %> | |||
| <!--#include file="../views/Boards/Edit.asp" --> | |||
| <% | |||
| Set board = Nothing | |||
| End Sub | |||
| ' POST /board/:slug/update | |||
| Public Sub Update(slug) | |||
| If Not KeycloakRequireLogin("") Then Exit Sub | |||
| Dim board : Set board = boards_Repository().FindBySlug(slug) | |||
| If board Is Nothing Then | |||
| Response.Status = "404 Not Found" | |||
| Response.Write "Board not found." | |||
| Exit Sub | |||
| End If | |||
| Dim newName : newName = Trim(CStr(Request.Form("name"))) | |||
| If Len(newName) = 0 Then | |||
| Dim flashUpdateErr : Set flashUpdateErr = Flash() | |||
| flashUpdateErr.AddError "Board name is required." | |||
| Response.Redirect "/board/" & slug & "/edit" | |||
| Exit Sub | |||
| End If | |||
| Dim newSlug : newSlug = boards_Repository().UniqueSlug(GenerateSlug(newName), CLng(board.id)) | |||
| board.name = newName | |||
| board.slug = newSlug | |||
| board.updated_at = Now() | |||
| board.updated_by = GetCurrentUsername() | |||
| boards_Repository().Update board | |||
| Dim flashUpdateOk : Set flashUpdateOk = Flash() | |||
| flashUpdateOk.Success = "Board updated." | |||
| Response.Redirect "/board/" & newSlug | |||
| End Sub | |||
| ' POST /board/:slug/delete | |||
| Public Sub Destroy(slug) | |||
| If Not KeycloakRequireLogin("") Then Exit Sub | |||
| Dim board : Set board = boards_Repository().FindBySlug(slug) | |||
| If board Is Nothing Then | |||
| Response.Redirect "/boards" | |||
| Exit Sub | |||
| End If | |||
| Dim boardId : boardId = CLng(board.id) | |||
| cards_Repository().DeleteByBoardId boardId | |||
| board_columns_Repository().DeleteByBoardId boardId | |||
| swim_lanes_Repository().DeleteByBoardId boardId | |||
| boards_Repository().Delete boardId | |||
| Dim flashDeleteOk : Set flashDeleteOk = Flash() | |||
| flashDeleteOk.Success = "Board deleted." | |||
| MVC.RedirectTo "Boards", "Index" | |||
| End Sub | |||
| Private Function GetCurrentUsername() | |||
| Dim u : Set u = KeycloakCurrentUser() | |||
| Dim name : name = "" | |||
| If Not u Is Nothing Then | |||
| On Error Resume Next | |||
| name = CStr(u.Item("preferred_username")) | |||
| If Err.Number <> 0 Then | |||
| name = "" | |||
| Err.Clear | |||
| End If | |||
| On Error GoTo 0 | |||
| End If | |||
| GetCurrentUsername = name | |||
| End Function | |||
| Private Function JsonStr(s) | |||
| Dim v : v = CStr(s) | |||
| v = Replace(v, "\", "\\") | |||
| v = Replace(v, """", "\""") | |||
| v = Replace(v, vbCrLf, "\n") | |||
| v = Replace(v, vbLf, "\n") | |||
| v = Replace(v, vbCr, "\n") | |||
| JsonStr = """" & v & """" | |||
| End Function | |||
| End Class | |||
| Dim BoardsController_Class__Singleton | |||
| Function BoardsController() | |||
| If IsEmpty(BoardsController_Class__Singleton) Then | |||
| Set BoardsController_Class__Singleton = New BoardsController_Class | |||
| End If | |||
| Set BoardsController = BoardsController_Class__Singleton | |||
| End Function | |||
| %> | |||
| @@ -0,0 +1,162 @@ | |||
| <% | |||
| Class CardsController_Class | |||
| Private m_useLayout | |||
| Private m_title | |||
| Private Sub Class_Initialize() | |||
| m_useLayout = False | |||
| m_title = "Cards" | |||
| End Sub | |||
| Public Property Get useLayout() : useLayout = m_useLayout : End Property | |||
| Public Property Let useLayout(v) : m_useLayout = v : End Property | |||
| Public Property Get Title() : Title = m_title : End Property | |||
| Public Property Let Title(v) : m_title = v : End Property | |||
| ' POST /cards | |||
| Public Sub Store() | |||
| Response.ContentType = "application/json" | |||
| If Not KeycloakIsLoggedIn() Then | |||
| Response.Write "{""ok"":false,""error"":""Unauthorized""}" | |||
| Exit Sub | |||
| End If | |||
| Dim boardId, columnId, swimLaneId, jobNum, jobName | |||
| boardId = CLng(Request.Form("board_id")) | |||
| columnId = CLng(Request.Form("column_id")) | |||
| swimLaneId = CLng(Request.Form("swim_lane_id")) | |||
| jobNum = Trim(CStr(Request.Form("job_number"))) | |||
| jobName = Trim(CStr(Request.Form("job_name"))) | |||
| If boardId = 0 Or columnId = 0 Or swimLaneId = 0 Then | |||
| Response.Write "{""ok"":false,""error"":""board_id, column_id, and swim_lane_id are required""}" | |||
| Exit Sub | |||
| End If | |||
| Dim nextPos : nextPos = cards_Repository().MaxPosition(columnId, swimLaneId) + 1 | |||
| Dim username : username = GetCurrentUsername() | |||
| Dim card : Set card = New POBO_cards | |||
| card.board_id = boardId | |||
| card.column_id = columnId | |||
| card.swim_lane_id = swimLaneId | |||
| card.job_number = jobNum | |||
| card.job_name = jobName | |||
| card.position = nextPos | |||
| card.created_at = Now() | |||
| card.created_by = username | |||
| card.updated_at = Now() | |||
| card.updated_by = username | |||
| cards_Repository().AddNew card | |||
| Response.Write "{""ok"":true,""id"":" & card.id & "," & _ | |||
| """job_number"":" & JsonString(card.job_number) & "," & _ | |||
| """job_name"":" & JsonString(card.job_name) & "," & _ | |||
| """column_id"":" & card.column_id & "," & _ | |||
| """swim_lane_id"":" & card.swim_lane_id & "," & _ | |||
| """position"":" & card.position & "}" | |||
| End Sub | |||
| ' POST /cards/:id | |||
| Public Sub Update(id) | |||
| Response.ContentType = "application/json" | |||
| If Not KeycloakIsLoggedIn() Then | |||
| Response.Write "{""ok"":false,""error"":""Unauthorized""}" | |||
| Exit Sub | |||
| End If | |||
| On Error Resume Next | |||
| Dim card : Set card = cards_Repository().FindByID(CLng(id)) | |||
| If Err.Number <> 0 Then | |||
| Err.Clear | |||
| Response.Write "{""ok"":false,""error"":""Not found""}" | |||
| Exit Sub | |||
| End If | |||
| On Error GoTo 0 | |||
| card.job_number = Trim(CStr(Request.Form("job_number"))) | |||
| card.job_name = Trim(CStr(Request.Form("job_name"))) | |||
| card.updated_at = Now() | |||
| card.updated_by = GetCurrentUsername() | |||
| cards_Repository().Update card | |||
| Response.Write "{""ok"":true," & _ | |||
| """job_number"":" & JsonString(card.job_number) & "," & _ | |||
| """job_name"":" & JsonString(card.job_name) & "}" | |||
| End Sub | |||
| ' POST /cards/:id/move — form: column_id, swim_lane_id, position, [sibling_ids CSV for reorder] | |||
| Public Sub Move(id) | |||
| Response.ContentType = "application/json" | |||
| If Not KeycloakIsLoggedIn() Then | |||
| Response.Write "{""ok"":false,""error"":""Unauthorized""}" | |||
| Exit Sub | |||
| End If | |||
| Dim columnId, swimLaneId, position | |||
| columnId = CLng(Request.Form("column_id")) | |||
| swimLaneId = CLng(Request.Form("swim_lane_id")) | |||
| position = CLng(Request.Form("position")) | |||
| Dim username : username = GetCurrentUsername() | |||
| cards_Repository().Move CLng(id), columnId, swimLaneId, position, Now(), username | |||
| ' Reorder siblings if provided | |||
| Dim siblings : siblings = Trim(CStr(Request.Form("sibling_ids"))) | |||
| If Len(siblings) > 0 Then | |||
| Dim ids, idx | |||
| ids = Split(siblings, ",") | |||
| For idx = 0 To UBound(ids) | |||
| Dim sibId : sibId = CLng(Trim(ids(idx))) | |||
| If sibId > 0 Then | |||
| cards_Repository().UpdatePosition sibId, idx, Now(), username | |||
| End If | |||
| Next | |||
| End If | |||
| Response.Write "{""ok"":true}" | |||
| End Sub | |||
| ' POST /cards/:id/delete | |||
| Public Sub Destroy(id) | |||
| Response.ContentType = "application/json" | |||
| If Not KeycloakIsLoggedIn() Then | |||
| Response.Write "{""ok"":false,""error"":""Unauthorized""}" | |||
| Exit Sub | |||
| End If | |||
| cards_Repository().Delete CLng(id) | |||
| Response.Write "{""ok"":true}" | |||
| End Sub | |||
| Private Function GetCurrentUsername() | |||
| Dim u : Set u = KeycloakCurrentUser() | |||
| Dim name : name = "" | |||
| If Not u Is Nothing Then | |||
| On Error Resume Next | |||
| name = CStr(u.Item("preferred_username")) | |||
| If Err.Number <> 0 Then | |||
| name = "" | |||
| Err.Clear | |||
| End If | |||
| On Error GoTo 0 | |||
| End If | |||
| GetCurrentUsername = name | |||
| End Function | |||
| Private Function JsonString(s) | |||
| JsonString = """" & Replace(Replace(CStr(s), "\", "\\"), """", "\""") & """" | |||
| End Function | |||
| End Class | |||
| Dim CardsController_Class__Singleton | |||
| Function CardsController() | |||
| If IsEmpty(CardsController_Class__Singleton) Then | |||
| Set CardsController_Class__Singleton = New CardsController_Class | |||
| End If | |||
| Set CardsController = CardsController_Class__Singleton | |||
| End Function | |||
| %> | |||
| @@ -0,0 +1,150 @@ | |||
| <% | |||
| Class ColumnsController_Class | |||
| Private m_useLayout | |||
| Private m_title | |||
| Private Sub Class_Initialize() | |||
| m_useLayout = False | |||
| m_title = "Columns" | |||
| End Sub | |||
| Public Property Get useLayout() : useLayout = m_useLayout : End Property | |||
| Public Property Let useLayout(v) : m_useLayout = v : End Property | |||
| Public Property Get Title() : Title = m_title : End Property | |||
| Public Property Let Title(v) : m_title = v : End Property | |||
| ' POST /columns | |||
| Public Sub Store() | |||
| Response.ContentType = "application/json" | |||
| If Not KeycloakIsLoggedIn() Then | |||
| Response.Write "{""ok"":false,""error"":""Unauthorized""}" | |||
| Exit Sub | |||
| End If | |||
| Dim boardId, colName | |||
| boardId = CLng(Request.Form("board_id")) | |||
| colName = Trim(CStr(Request.Form("name"))) | |||
| If boardId = 0 Or Len(colName) = 0 Then | |||
| Response.Write "{""ok"":false,""error"":""board_id and name are required"",""debug_board_id_raw"":""" & Request.Form("board_id") & """,""debug_name_raw"":""" & Request.Form("name") & """,""debug_board_id_clng"":" & boardId & "}" | |||
| Exit Sub | |||
| End If | |||
| Dim nextPos : nextPos = board_columns_Repository().MaxPosition(boardId) + 1 | |||
| Dim username : username = GetCurrentUsername() | |||
| Dim col : Set col = New POBO_board_columns | |||
| col.board_id = boardId | |||
| col.name = colName | |||
| col.position = nextPos | |||
| col.created_at = Now() | |||
| col.created_by = username | |||
| col.updated_at = Now() | |||
| col.updated_by = username | |||
| board_columns_Repository().AddNew col | |||
| Response.Write "{""ok"":true,""id"":" & col.id & ",""name"":" & JsonString(col.name) & ",""position"":" & col.position & "}" | |||
| End Sub | |||
| ' POST /columns/:id | |||
| Public Sub Update(id) | |||
| Response.ContentType = "application/json" | |||
| If Not KeycloakIsLoggedIn() Then | |||
| Response.Write "{""ok"":false,""error"":""Unauthorized""}" | |||
| Exit Sub | |||
| End If | |||
| Dim colName : colName = Trim(CStr(Request.Form("name"))) | |||
| If Len(colName) = 0 Then | |||
| Response.Write "{""ok"":false,""error"":""name is required""}" | |||
| Exit Sub | |||
| End If | |||
| On Error Resume Next | |||
| Dim col : Set col = board_columns_Repository().FindByID(CLng(id)) | |||
| If Err.Number <> 0 Then | |||
| Err.Clear | |||
| Response.Write "{""ok"":false,""error"":""Not found""}" | |||
| Exit Sub | |||
| End If | |||
| On Error GoTo 0 | |||
| col.name = colName | |||
| col.updated_at = Now() | |||
| col.updated_by = GetCurrentUsername() | |||
| board_columns_Repository().Update col | |||
| Response.Write "{""ok"":true}" | |||
| End Sub | |||
| ' POST /columns/:id/delete | |||
| Public Sub Destroy(id) | |||
| Response.ContentType = "application/json" | |||
| If Not KeycloakIsLoggedIn() Then | |||
| Response.Write "{""ok"":false,""error"":""Unauthorized""}" | |||
| Exit Sub | |||
| End If | |||
| Dim colId : colId = CLng(id) | |||
| cards_Repository().DeleteByColumnId colId | |||
| board_columns_Repository().Delete colId | |||
| Response.Write "{""ok"":true}" | |||
| End Sub | |||
| ' POST /columns/reorder — body: JSON array [{id:1,position:0},{id:2,position:1},...] | |||
| Public Sub Reorder() | |||
| Response.ContentType = "application/json" | |||
| If Not KeycloakIsLoggedIn() Then | |||
| Response.Write "{""ok"":false,""error"":""Unauthorized""}" | |||
| Exit Sub | |||
| End If | |||
| Dim rawJson : rawJson = GetRawJsonFromRequest() | |||
| Dim parsed : Set parsed = JSON.parse(rawJson) | |||
| If IsNull(parsed) Or IsEmpty(parsed) Then | |||
| Response.Write "{""ok"":false,""error"":""Invalid JSON""}" | |||
| Exit Sub | |||
| End If | |||
| Dim username : username = GetCurrentUsername() | |||
| Dim i, item | |||
| For i = 0 To parsed.Count - 1 | |||
| Set item = parsed.Item(i) | |||
| board_columns_Repository().UpdatePosition CLng(item.Item("id")), CLng(item.Item("position")), Now(), username | |||
| Next | |||
| Response.Write "{""ok"":true}" | |||
| End Sub | |||
| Private Function GetCurrentUsername() | |||
| Dim u : Set u = KeycloakCurrentUser() | |||
| Dim name : name = "" | |||
| If Not u Is Nothing Then | |||
| On Error Resume Next | |||
| name = CStr(u.Item("preferred_username")) | |||
| If Err.Number <> 0 Then | |||
| name = "" | |||
| Err.Clear | |||
| End If | |||
| On Error GoTo 0 | |||
| End If | |||
| GetCurrentUsername = name | |||
| End Function | |||
| Private Function JsonString(s) | |||
| JsonString = """" & Replace(Replace(CStr(s), "\", "\\"), """", "\""") & """" | |||
| End Function | |||
| End Class | |||
| Dim ColumnsController_Class__Singleton | |||
| Function ColumnsController() | |||
| If IsEmpty(ColumnsController_Class__Singleton) Then | |||
| Set ColumnsController_Class__Singleton = New ColumnsController_Class | |||
| End If | |||
| Set ColumnsController = ColumnsController_Class__Singleton | |||
| End Function | |||
| %> | |||
| @@ -0,0 +1,150 @@ | |||
| <% | |||
| Class SwimLanesController_Class | |||
| Private m_useLayout | |||
| Private m_title | |||
| Private Sub Class_Initialize() | |||
| m_useLayout = False | |||
| m_title = "Swim Lanes" | |||
| End Sub | |||
| Public Property Get useLayout() : useLayout = m_useLayout : End Property | |||
| Public Property Let useLayout(v) : m_useLayout = v : End Property | |||
| Public Property Get Title() : Title = m_title : End Property | |||
| Public Property Let Title(v) : m_title = v : End Property | |||
| ' POST /swimlanes | |||
| Public Sub Store() | |||
| Response.ContentType = "application/json" | |||
| If Not KeycloakIsLoggedIn() Then | |||
| Response.Write "{""ok"":false,""error"":""Unauthorized""}" | |||
| Exit Sub | |||
| End If | |||
| Dim boardId, laneName | |||
| boardId = CLng(Request.Form("board_id")) | |||
| laneName = Trim(CStr(Request.Form("name"))) | |||
| If boardId = 0 Or Len(laneName) = 0 Then | |||
| Response.Write "{""ok"":false,""error"":""board_id and name are required""}" | |||
| Exit Sub | |||
| End If | |||
| Dim nextPos : nextPos = swim_lanes_Repository().MaxPosition(boardId) + 1 | |||
| Dim username : username = GetCurrentUsername() | |||
| Dim lane : Set lane = New POBO_swim_lanes | |||
| lane.board_id = boardId | |||
| lane.name = laneName | |||
| lane.position = nextPos | |||
| lane.created_at = Now() | |||
| lane.created_by = username | |||
| lane.updated_at = Now() | |||
| lane.updated_by = username | |||
| swim_lanes_Repository().AddNew lane | |||
| Response.Write "{""ok"":true,""id"":" & lane.id & ",""name"":" & JsonString(lane.name) & ",""position"":" & lane.position & "}" | |||
| End Sub | |||
| ' POST /swimlanes/:id | |||
| Public Sub Update(id) | |||
| Response.ContentType = "application/json" | |||
| If Not KeycloakIsLoggedIn() Then | |||
| Response.Write "{""ok"":false,""error"":""Unauthorized""}" | |||
| Exit Sub | |||
| End If | |||
| Dim laneName : laneName = Trim(CStr(Request.Form("name"))) | |||
| If Len(laneName) = 0 Then | |||
| Response.Write "{""ok"":false,""error"":""name is required""}" | |||
| Exit Sub | |||
| End If | |||
| On Error Resume Next | |||
| Dim lane : Set lane = swim_lanes_Repository().FindByID(CLng(id)) | |||
| If Err.Number <> 0 Then | |||
| Err.Clear | |||
| Response.Write "{""ok"":false,""error"":""Not found""}" | |||
| Exit Sub | |||
| End If | |||
| On Error GoTo 0 | |||
| lane.name = laneName | |||
| lane.updated_at = Now() | |||
| lane.updated_by = GetCurrentUsername() | |||
| swim_lanes_Repository().Update lane | |||
| Response.Write "{""ok"":true}" | |||
| End Sub | |||
| ' POST /swimlanes/:id/delete | |||
| Public Sub Destroy(id) | |||
| Response.ContentType = "application/json" | |||
| If Not KeycloakIsLoggedIn() Then | |||
| Response.Write "{""ok"":false,""error"":""Unauthorized""}" | |||
| Exit Sub | |||
| End If | |||
| Dim laneId : laneId = CLng(id) | |||
| cards_Repository().DeleteBySwimLaneId laneId | |||
| swim_lanes_Repository().Delete laneId | |||
| Response.Write "{""ok"":true}" | |||
| End Sub | |||
| ' POST /swimlanes/reorder — body: JSON array [{id:1,position:0},{id:2,position:1},...] | |||
| Public Sub Reorder() | |||
| Response.ContentType = "application/json" | |||
| If Not KeycloakIsLoggedIn() Then | |||
| Response.Write "{""ok"":false,""error"":""Unauthorized""}" | |||
| Exit Sub | |||
| End If | |||
| Dim rawJson : rawJson = GetRawJsonFromRequest() | |||
| Dim parsed : Set parsed = JSON.parse(rawJson) | |||
| If IsNull(parsed) Or IsEmpty(parsed) Then | |||
| Response.Write "{""ok"":false,""error"":""Invalid JSON""}" | |||
| Exit Sub | |||
| End If | |||
| Dim username : username = GetCurrentUsername() | |||
| Dim i, item | |||
| For i = 0 To parsed.Count - 1 | |||
| Set item = parsed.Item(i) | |||
| swim_lanes_Repository().UpdatePosition CLng(item.Item("id")), CLng(item.Item("position")), Now(), username | |||
| Next | |||
| Response.Write "{""ok"":true}" | |||
| End Sub | |||
| Private Function GetCurrentUsername() | |||
| Dim u : Set u = KeycloakCurrentUser() | |||
| Dim name : name = "" | |||
| If Not u Is Nothing Then | |||
| On Error Resume Next | |||
| name = CStr(u.Item("preferred_username")) | |||
| If Err.Number <> 0 Then | |||
| name = "" | |||
| Err.Clear | |||
| End If | |||
| On Error GoTo 0 | |||
| End If | |||
| GetCurrentUsername = name | |||
| End Function | |||
| Private Function JsonString(s) | |||
| JsonString = """" & Replace(Replace(CStr(s), "\", "\\"), """", "\""") & """" | |||
| End Function | |||
| End Class | |||
| Dim SwimLanesController_Class__Singleton | |||
| Function SwimLanesController() | |||
| If IsEmpty(SwimLanesController_Class__Singleton) Then | |||
| Set SwimLanesController_Class__Singleton = New SwimLanesController_Class | |||
| End If | |||
| Set SwimLanesController = SwimLanesController_Class__Singleton | |||
| End Function | |||
| %> | |||
| @@ -1,3 +1,15 @@ | |||
| <!--#include file="HomeController.asp" --> | |||
| <!--#include file="ErrorController.asp" --> | |||
| <!--#include file="AuthController.asp" --> | |||
| <!--#include file="AuthController.asp" --> | |||
| <!--#include file="BoardsController.asp" --> | |||
| <!--#include file="ColumnsController.asp" --> | |||
| <!--#include file="SwimLanesController.asp" --> | |||
| <!--#include file="CardsController.asp" --> | |||
| <!--#include file="../models/POBO_boards.asp" --> | |||
| <!--#include file="../models/POBO_board_columns.asp" --> | |||
| <!--#include file="../models/POBO_swim_lanes.asp" --> | |||
| <!--#include file="../models/POBO_cards.asp" --> | |||
| <!--#include file="../repositories/boards_Repository.asp" --> | |||
| <!--#include file="../repositories/board_columns_Repository.asp" --> | |||
| <!--#include file="../repositories/swim_lanes_Repository.asp" --> | |||
| <!--#include file="../repositories/cards_Repository.asp" --> | |||
| @@ -0,0 +1,53 @@ | |||
| <% | |||
| Class POBO_board_columns | |||
| Public Properties | |||
| Private p_id | |||
| Private p_board_id | |||
| Private p_name | |||
| Private p_position | |||
| Private p_created_at | |||
| Private p_created_by | |||
| Private p_updated_at | |||
| Private p_updated_by | |||
| Private Sub Class_Initialize() | |||
| p_id = 0 | |||
| p_board_id = 0 | |||
| p_name = "" | |||
| p_position = 0 | |||
| p_created_at = #1/1/1970# | |||
| p_created_by = "" | |||
| p_updated_at = #1/1/1970# | |||
| p_updated_by = "" | |||
| Properties = Array("id","board_id","name","position","created_at","created_by","updated_at","updated_by") | |||
| End Sub | |||
| Public Property Get PrimaryKey() : PrimaryKey = "id" : End Property | |||
| Public Property Get TableName() : TableName = "board_columns" : End Property | |||
| Public Property Get id() : id = p_id : End Property | |||
| Public Property Let id(v) : p_id = CDbl(v) : End Property | |||
| Public Property Get board_id() : board_id = p_board_id : End Property | |||
| Public Property Let board_id(v): p_board_id = CDbl(v) : End Property | |||
| Public Property Get name() : name = p_name : End Property | |||
| Public Property Let name(v) : p_name = CStr(v) : End Property | |||
| Public Property Get position() : position = p_position : End Property | |||
| Public Property Let position(v): p_position = CDbl(v) : End Property | |||
| Public Property Get created_at() : created_at = p_created_at : End Property | |||
| Public Property Let created_at(v) : p_created_at = CDate(v) : End Property | |||
| Public Property Get created_by() : created_by = p_created_by : End Property | |||
| Public Property Let created_by(v) : p_created_by = CStr(v) : End Property | |||
| Public Property Get updated_at() : updated_at = p_updated_at : End Property | |||
| Public Property Let updated_at(v) : p_updated_at = CDate(v) : End Property | |||
| Public Property Get updated_by() : updated_by = p_updated_by : End Property | |||
| Public Property Let updated_by(v) : p_updated_by = CStr(v) : End Property | |||
| End Class | |||
| %> | |||
| @@ -0,0 +1,48 @@ | |||
| <% | |||
| Class POBO_boards | |||
| Public Properties | |||
| Private p_id | |||
| Private p_name | |||
| Private p_slug | |||
| Private p_created_at | |||
| Private p_created_by | |||
| Private p_updated_at | |||
| Private p_updated_by | |||
| Private Sub Class_Initialize() | |||
| p_id = 0 | |||
| p_name = "" | |||
| p_slug = "" | |||
| p_created_at = #1/1/1970# | |||
| p_created_by = "" | |||
| p_updated_at = #1/1/1970# | |||
| p_updated_by = "" | |||
| Properties = Array("id","name","slug","created_at","created_by","updated_at","updated_by") | |||
| End Sub | |||
| Public Property Get PrimaryKey() : PrimaryKey = "id" : End Property | |||
| Public Property Get TableName() : TableName = "boards" : End Property | |||
| Public Property Get id() : id = p_id : End Property | |||
| Public Property Let id(v) : p_id = CDbl(v) : End Property | |||
| Public Property Get name() : name = p_name : End Property | |||
| Public Property Let name(v) : p_name = CStr(v) : End Property | |||
| Public Property Get slug() : slug = p_slug : End Property | |||
| Public Property Let slug(v) : p_slug = CStr(v) : End Property | |||
| Public Property Get created_at() : created_at = p_created_at : End Property | |||
| Public Property Let created_at(v) : p_created_at = CDate(v) : End Property | |||
| Public Property Get created_by() : created_by = p_created_by : End Property | |||
| Public Property Let created_by(v) : p_created_by = CStr(v) : End Property | |||
| Public Property Get updated_at() : updated_at = p_updated_at : End Property | |||
| Public Property Let updated_at(v) : p_updated_at = CDate(v) : End Property | |||
| Public Property Get updated_by() : updated_by = p_updated_by : End Property | |||
| Public Property Let updated_by(v) : p_updated_by = CStr(v) : End Property | |||
| End Class | |||
| %> | |||
| @@ -0,0 +1,68 @@ | |||
| <% | |||
| Class POBO_cards | |||
| Public Properties | |||
| Private p_id | |||
| Private p_board_id | |||
| Private p_column_id | |||
| Private p_swim_lane_id | |||
| Private p_job_number | |||
| Private p_job_name | |||
| Private p_position | |||
| Private p_created_at | |||
| Private p_created_by | |||
| Private p_updated_at | |||
| Private p_updated_by | |||
| Private Sub Class_Initialize() | |||
| p_id = 0 | |||
| p_board_id = 0 | |||
| p_column_id = 0 | |||
| p_swim_lane_id = 0 | |||
| p_job_number = "" | |||
| p_job_name = "" | |||
| p_position = 0 | |||
| p_created_at = #1/1/1970# | |||
| p_created_by = "" | |||
| p_updated_at = #1/1/1970# | |||
| p_updated_by = "" | |||
| Properties = Array("id","board_id","column_id","swim_lane_id","job_number","job_name","position","created_at","created_by","updated_at","updated_by") | |||
| End Sub | |||
| Public Property Get PrimaryKey() : PrimaryKey = "id" : End Property | |||
| Public Property Get TableName() : TableName = "cards" : End Property | |||
| Public Property Get id() : id = p_id : End Property | |||
| Public Property Let id(v) : p_id = CDbl(v) : End Property | |||
| Public Property Get board_id() : board_id = p_board_id : End Property | |||
| Public Property Let board_id(v) : p_board_id = CDbl(v) : End Property | |||
| Public Property Get column_id() : column_id = p_column_id : End Property | |||
| Public Property Let column_id(v) : p_column_id = CDbl(v) : End Property | |||
| Public Property Get swim_lane_id() : swim_lane_id = p_swim_lane_id : End Property | |||
| Public Property Let swim_lane_id(v): p_swim_lane_id = CDbl(v) : End Property | |||
| Public Property Get job_number() : job_number = p_job_number : End Property | |||
| Public Property Let job_number(v) : p_job_number = CStr(v) : End Property | |||
| Public Property Get job_name() : job_name = p_job_name : End Property | |||
| Public Property Let job_name(v) : p_job_name = CStr(v) : End Property | |||
| Public Property Get position() : position = p_position : End Property | |||
| Public Property Let position(v) : p_position = CDbl(v) : End Property | |||
| Public Property Get created_at() : created_at = p_created_at : End Property | |||
| Public Property Let created_at(v) : p_created_at = CDate(v) : End Property | |||
| Public Property Get created_by() : created_by = p_created_by : End Property | |||
| Public Property Let created_by(v) : p_created_by = CStr(v) : End Property | |||
| Public Property Get updated_at() : updated_at = p_updated_at : End Property | |||
| Public Property Let updated_at(v) : p_updated_at = CDate(v) : End Property | |||
| Public Property Get updated_by() : updated_by = p_updated_by : End Property | |||
| Public Property Let updated_by(v) : p_updated_by = CStr(v) : End Property | |||
| End Class | |||
| %> | |||
| @@ -0,0 +1,53 @@ | |||
| <% | |||
| Class POBO_swim_lanes | |||
| Public Properties | |||
| Private p_id | |||
| Private p_board_id | |||
| Private p_name | |||
| Private p_position | |||
| Private p_created_at | |||
| Private p_created_by | |||
| Private p_updated_at | |||
| Private p_updated_by | |||
| Private Sub Class_Initialize() | |||
| p_id = 0 | |||
| p_board_id = 0 | |||
| p_name = "" | |||
| p_position = 0 | |||
| p_created_at = #1/1/1970# | |||
| p_created_by = "" | |||
| p_updated_at = #1/1/1970# | |||
| p_updated_by = "" | |||
| Properties = Array("id","board_id","name","position","created_at","created_by","updated_at","updated_by") | |||
| End Sub | |||
| Public Property Get PrimaryKey() : PrimaryKey = "id" : End Property | |||
| Public Property Get TableName() : TableName = "swim_lanes" : End Property | |||
| Public Property Get id() : id = p_id : End Property | |||
| Public Property Let id(v) : p_id = CDbl(v) : End Property | |||
| Public Property Get board_id() : board_id = p_board_id : End Property | |||
| Public Property Let board_id(v): p_board_id = CDbl(v) : End Property | |||
| Public Property Get name() : name = p_name : End Property | |||
| Public Property Let name(v) : p_name = CStr(v) : End Property | |||
| Public Property Get position() : position = p_position : End Property | |||
| Public Property Let position(v): p_position = CDbl(v) : End Property | |||
| Public Property Get created_at() : created_at = p_created_at : End Property | |||
| Public Property Let created_at(v) : p_created_at = CDate(v) : End Property | |||
| Public Property Get created_by() : created_by = p_created_by : End Property | |||
| Public Property Let created_by(v) : p_created_by = CStr(v) : End Property | |||
| Public Property Get updated_at() : updated_at = p_updated_at : End Property | |||
| Public Property Let updated_at(v) : p_updated_at = CDate(v) : End Property | |||
| Public Property Get updated_by() : updated_by = p_updated_by : End Property | |||
| Public Property Let updated_by(v) : p_updated_by = CStr(v) : End Property | |||
| End Class | |||
| %> | |||
| @@ -0,0 +1,75 @@ | |||
| <% | |||
| Class board_columns_Repository_Class | |||
| Public Function FindByID(id) | |||
| Dim sql : sql = "SELECT [id],[board_id],[name],[position],[created_at],[created_by],[updated_at],[updated_by] FROM [board_columns] WHERE [id] = ?" | |||
| Dim rs : Set rs = DAL.Query(sql, Array(id)) | |||
| If rs.EOF Then | |||
| Err.Raise 1, "board_columns_Repository_Class", "Column not found with id = " & id | |||
| Else | |||
| Set FindByID = Automapper.AutoMap(rs, "POBO_board_columns") | |||
| End If | |||
| Destroy rs | |||
| End Function | |||
| Public Function FindByBoardId(boardId) | |||
| Dim sql : sql = "SELECT [id],[board_id],[name],[position],[created_at],[created_by],[updated_at],[updated_by] FROM [board_columns] WHERE [board_id] = ? ORDER BY [position] ASC" | |||
| Dim rs : Set rs = DAL.Query(sql, Array(boardId)) | |||
| Dim list : Set list = New LinkedList_Class | |||
| Do Until rs.EOF | |||
| list.Push Automapper.AutoMap(rs, "POBO_board_columns") | |||
| rs.MoveNext | |||
| Loop | |||
| Set FindByBoardId = list | |||
| Destroy rs | |||
| End Function | |||
| Public Function MaxPosition(boardId) | |||
| Dim sql : sql = "SELECT MAX([position]) FROM [board_columns] WHERE [board_id] = ?" | |||
| Dim rs : Set rs = DAL.Query(sql, Array(boardId)) | |||
| If rs.EOF Or IsNull(rs(0)) Then | |||
| MaxPosition = -1 | |||
| Else | |||
| MaxPosition = CLng(rs(0)) | |||
| End If | |||
| Destroy rs | |||
| End Function | |||
| Public Sub AddNew(ByRef model) | |||
| Dim sql : sql = "INSERT INTO [board_columns] ([board_id],[name],[position],[created_at],[created_by],[updated_at],[updated_by]) VALUES (?,?,?,?,?,?,?)" | |||
| DAL.Execute sql, Array(model.board_id, model.name, model.position, model.created_at, model.created_by, model.updated_at, model.updated_by) | |||
| Dim rsId : Set rsId = DAL.Query("SELECT @@IDENTITY AS NewID", Empty) | |||
| If Not rsId.EOF Then | |||
| If Not IsNull(rsId(0)) Then model.id = rsId(0) | |||
| End If | |||
| Destroy rsId | |||
| End Sub | |||
| Public Sub Update(model) | |||
| Dim sql : sql = "UPDATE [board_columns] SET [name]=?,[position]=?,[updated_at]=?,[updated_by]=? WHERE [id]=?" | |||
| DAL.Execute sql, Array(model.name, model.position, model.updated_at, model.updated_by, model.id) | |||
| End Sub | |||
| Public Sub UpdatePosition(id, position, updatedAt, updatedBy) | |||
| Dim sql : sql = "UPDATE [board_columns] SET [position]=?,[updated_at]=?,[updated_by]=? WHERE [id]=?" | |||
| DAL.Execute sql, Array(position, updatedAt, updatedBy, id) | |||
| End Sub | |||
| Public Sub Delete(id) | |||
| DAL.Execute "DELETE FROM [board_columns] WHERE [id]=?", Array(id) | |||
| End Sub | |||
| Public Sub DeleteByBoardId(boardId) | |||
| DAL.Execute "DELETE FROM [board_columns] WHERE [board_id]=?", Array(boardId) | |||
| End Sub | |||
| End Class | |||
| Dim board_columns_Repository__Singleton | |||
| Function board_columns_Repository() | |||
| If IsEmpty(board_columns_Repository__Singleton) Then | |||
| Set board_columns_Repository__Singleton = New board_columns_Repository_Class | |||
| End If | |||
| Set board_columns_Repository = board_columns_Repository__Singleton | |||
| End Function | |||
| %> | |||
| @@ -0,0 +1,90 @@ | |||
| <% | |||
| Class boards_Repository_Class | |||
| Public Function FindByID(id) | |||
| Dim sql : sql = "SELECT [id],[name],[slug],[created_at],[created_by],[updated_at],[updated_by] FROM [boards] WHERE [id] = ?" | |||
| Dim rs : Set rs = DAL.Query(sql, Array(id)) | |||
| If rs.EOF Then | |||
| Err.Raise 1, "boards_Repository_Class", "Board not found with id = " & id | |||
| Else | |||
| Set FindByID = Automapper.AutoMap(rs, "POBO_boards") | |||
| End If | |||
| Destroy rs | |||
| End Function | |||
| Public Function FindBySlug(slug) | |||
| Dim sql : sql = "SELECT [id],[name],[slug],[created_at],[created_by],[updated_at],[updated_by] FROM [boards] WHERE [slug] = ?" | |||
| Dim rs : Set rs = DAL.Query(sql, Array(slug)) | |||
| If rs.EOF Then | |||
| Set FindBySlug = Nothing | |||
| Else | |||
| Set FindBySlug = Automapper.AutoMap(rs, "POBO_boards") | |||
| End If | |||
| Destroy rs | |||
| End Function | |||
| Public Function GetAll() | |||
| Dim sql : sql = "SELECT [id],[name],[slug],[created_at],[created_by],[updated_at],[updated_by] FROM [boards] ORDER BY [name] ASC" | |||
| Dim rs : Set rs = DAL.Query(sql, Empty) | |||
| Dim list : Set list = New LinkedList_Class | |||
| Do Until rs.EOF | |||
| list.Push Automapper.AutoMap(rs, "POBO_boards") | |||
| rs.MoveNext | |||
| Loop | |||
| Set GetAll = list | |||
| Destroy rs | |||
| End Function | |||
| Public Function SlugExists(slug, excludeId) | |||
| Dim sql, rs | |||
| If CLng(excludeId) > 0 Then | |||
| sql = "SELECT COUNT(*) FROM [boards] WHERE [slug] = ? AND [id] <> ?" | |||
| Set rs = DAL.Query(sql, Array(slug, excludeId)) | |||
| Else | |||
| sql = "SELECT COUNT(*) FROM [boards] WHERE [slug] = ?" | |||
| Set rs = DAL.Query(sql, Array(slug)) | |||
| End If | |||
| SlugExists = (rs(0) > 0) | |||
| Destroy rs | |||
| End Function | |||
| Public Function UniqueSlug(baseSlug, excludeId) | |||
| Dim candidate, suffix | |||
| candidate = baseSlug | |||
| suffix = 2 | |||
| Do While SlugExists(candidate, excludeId) | |||
| candidate = baseSlug & "-" & suffix | |||
| suffix = suffix + 1 | |||
| Loop | |||
| UniqueSlug = candidate | |||
| End Function | |||
| Public Sub AddNew(ByRef model) | |||
| Dim sql : sql = "INSERT INTO [boards] ([name],[slug],[created_at],[created_by],[updated_at],[updated_by]) VALUES (?,?,?,?,?,?)" | |||
| DAL.Execute sql, Array(model.name, model.slug, model.created_at, model.created_by, model.updated_at, model.updated_by) | |||
| Dim rsId : Set rsId = DAL.Query("SELECT @@IDENTITY AS NewID", Empty) | |||
| If Not rsId.EOF Then | |||
| If Not IsNull(rsId(0)) Then model.id = rsId(0) | |||
| End If | |||
| Destroy rsId | |||
| End Sub | |||
| Public Sub Update(model) | |||
| Dim sql : sql = "UPDATE [boards] SET [name]=?,[slug]=?,[updated_at]=?,[updated_by]=? WHERE [id]=?" | |||
| DAL.Execute sql, Array(model.name, model.slug, model.updated_at, model.updated_by, model.id) | |||
| End Sub | |||
| Public Sub Delete(id) | |||
| DAL.Execute "DELETE FROM [boards] WHERE [id]=?", Array(id) | |||
| End Sub | |||
| End Class | |||
| Dim boards_Repository__Singleton | |||
| Function boards_Repository() | |||
| If IsEmpty(boards_Repository__Singleton) Then | |||
| Set boards_Repository__Singleton = New boards_Repository_Class | |||
| End If | |||
| Set boards_Repository = boards_Repository__Singleton | |||
| End Function | |||
| %> | |||
| @@ -0,0 +1,104 @@ | |||
| <% | |||
| Class cards_Repository_Class | |||
| Private Function SelectBase() | |||
| SelectBase = "SELECT [id],[board_id],[column_id],[swim_lane_id],[job_number],[job_name],[position],[created_at],[created_by],[updated_at],[updated_by] FROM [cards]" | |||
| End Function | |||
| Public Function FindByID(id) | |||
| Dim sql : sql = SelectBase() & " WHERE [id] = ?" | |||
| Dim rs : Set rs = DAL.Query(sql, Array(id)) | |||
| If rs.EOF Then | |||
| Err.Raise 1, "cards_Repository_Class", "Card not found with id = " & id | |||
| Else | |||
| Set FindByID = Automapper.AutoMap(rs, "POBO_cards") | |||
| End If | |||
| Destroy rs | |||
| End Function | |||
| Public Function FindByBoardId(boardId) | |||
| Dim sql : sql = SelectBase() & " WHERE [board_id] = ? ORDER BY [swim_lane_id] ASC, [column_id] ASC, [position] ASC" | |||
| Dim rs : Set rs = DAL.Query(sql, Array(boardId)) | |||
| Dim list : Set list = New LinkedList_Class | |||
| Do Until rs.EOF | |||
| list.Push Automapper.AutoMap(rs, "POBO_cards") | |||
| rs.MoveNext | |||
| Loop | |||
| Set FindByBoardId = list | |||
| Destroy rs | |||
| End Function | |||
| Public Function FindByCell(columnId, swimLaneId) | |||
| Dim sql : sql = SelectBase() & " WHERE [column_id] = ? AND [swim_lane_id] = ? ORDER BY [position] ASC" | |||
| Dim rs : Set rs = DAL.Query(sql, Array(columnId, swimLaneId)) | |||
| Dim list : Set list = New LinkedList_Class | |||
| Do Until rs.EOF | |||
| list.Push Automapper.AutoMap(rs, "POBO_cards") | |||
| rs.MoveNext | |||
| Loop | |||
| Set FindByCell = list | |||
| Destroy rs | |||
| End Function | |||
| Public Function MaxPosition(columnId, swimLaneId) | |||
| Dim sql : sql = "SELECT MAX([position]) FROM [cards] WHERE [column_id] = ? AND [swim_lane_id] = ?" | |||
| Dim rs : Set rs = DAL.Query(sql, Array(columnId, swimLaneId)) | |||
| If rs.EOF Or IsNull(rs(0)) Then | |||
| MaxPosition = -1 | |||
| Else | |||
| MaxPosition = CLng(rs(0)) | |||
| End If | |||
| Destroy rs | |||
| End Function | |||
| Public Sub AddNew(ByRef model) | |||
| Dim sql : sql = "INSERT INTO [cards] ([board_id],[column_id],[swim_lane_id],[job_number],[job_name],[position],[created_at],[created_by],[updated_at],[updated_by]) VALUES (?,?,?,?,?,?,?,?,?,?)" | |||
| DAL.Execute sql, Array(model.board_id, model.column_id, model.swim_lane_id, model.job_number, model.job_name, model.position, model.created_at, model.created_by, model.updated_at, model.updated_by) | |||
| Dim rsId : Set rsId = DAL.Query("SELECT @@IDENTITY AS NewID", Empty) | |||
| If Not rsId.EOF Then | |||
| If Not IsNull(rsId(0)) Then model.id = rsId(0) | |||
| End If | |||
| Destroy rsId | |||
| End Sub | |||
| Public Sub Update(model) | |||
| Dim sql : sql = "UPDATE [cards] SET [job_number]=?,[job_name]=?,[updated_at]=?,[updated_by]=? WHERE [id]=?" | |||
| DAL.Execute sql, Array(model.job_number, model.job_name, model.updated_at, model.updated_by, model.id) | |||
| End Sub | |||
| Public Sub Move(id, columnId, swimLaneId, position, updatedAt, updatedBy) | |||
| Dim sql : sql = "UPDATE [cards] SET [column_id]=?,[swim_lane_id]=?,[position]=?,[updated_at]=?,[updated_by]=? WHERE [id]=?" | |||
| DAL.Execute sql, Array(columnId, swimLaneId, position, updatedAt, updatedBy, id) | |||
| End Sub | |||
| Public Sub UpdatePosition(id, position, updatedAt, updatedBy) | |||
| Dim sql : sql = "UPDATE [cards] SET [position]=?,[updated_at]=?,[updated_by]=? WHERE [id]=?" | |||
| DAL.Execute sql, Array(position, updatedAt, updatedBy, id) | |||
| End Sub | |||
| Public Sub Delete(id) | |||
| DAL.Execute "DELETE FROM [cards] WHERE [id]=?", Array(id) | |||
| End Sub | |||
| Public Sub DeleteByBoardId(boardId) | |||
| DAL.Execute "DELETE FROM [cards] WHERE [board_id]=?", Array(boardId) | |||
| End Sub | |||
| Public Sub DeleteByColumnId(columnId) | |||
| DAL.Execute "DELETE FROM [cards] WHERE [column_id]=?", Array(columnId) | |||
| End Sub | |||
| Public Sub DeleteBySwimLaneId(swimLaneId) | |||
| DAL.Execute "DELETE FROM [cards] WHERE [swim_lane_id]=?", Array(swimLaneId) | |||
| End Sub | |||
| End Class | |||
| Dim cards_Repository__Singleton | |||
| Function cards_Repository() | |||
| If IsEmpty(cards_Repository__Singleton) Then | |||
| Set cards_Repository__Singleton = New cards_Repository_Class | |||
| End If | |||
| Set cards_Repository = cards_Repository__Singleton | |||
| End Function | |||
| %> | |||
| @@ -0,0 +1,75 @@ | |||
| <% | |||
| Class swim_lanes_Repository_Class | |||
| Public Function FindByID(id) | |||
| Dim sql : sql = "SELECT [id],[board_id],[name],[position],[created_at],[created_by],[updated_at],[updated_by] FROM [swim_lanes] WHERE [id] = ?" | |||
| Dim rs : Set rs = DAL.Query(sql, Array(id)) | |||
| If rs.EOF Then | |||
| Err.Raise 1, "swim_lanes_Repository_Class", "Swim lane not found with id = " & id | |||
| Else | |||
| Set FindByID = Automapper.AutoMap(rs, "POBO_swim_lanes") | |||
| End If | |||
| Destroy rs | |||
| End Function | |||
| Public Function FindByBoardId(boardId) | |||
| Dim sql : sql = "SELECT [id],[board_id],[name],[position],[created_at],[created_by],[updated_at],[updated_by] FROM [swim_lanes] WHERE [board_id] = ? ORDER BY [position] ASC" | |||
| Dim rs : Set rs = DAL.Query(sql, Array(boardId)) | |||
| Dim list : Set list = New LinkedList_Class | |||
| Do Until rs.EOF | |||
| list.Push Automapper.AutoMap(rs, "POBO_swim_lanes") | |||
| rs.MoveNext | |||
| Loop | |||
| Set FindByBoardId = list | |||
| Destroy rs | |||
| End Function | |||
| Public Function MaxPosition(boardId) | |||
| Dim sql : sql = "SELECT MAX([position]) FROM [swim_lanes] WHERE [board_id] = ?" | |||
| Dim rs : Set rs = DAL.Query(sql, Array(boardId)) | |||
| If rs.EOF Or IsNull(rs(0)) Then | |||
| MaxPosition = -1 | |||
| Else | |||
| MaxPosition = CLng(rs(0)) | |||
| End If | |||
| Destroy rs | |||
| End Function | |||
| Public Sub AddNew(ByRef model) | |||
| Dim sql : sql = "INSERT INTO [swim_lanes] ([board_id],[name],[position],[created_at],[created_by],[updated_at],[updated_by]) VALUES (?,?,?,?,?,?,?)" | |||
| DAL.Execute sql, Array(model.board_id, model.name, model.position, model.created_at, model.created_by, model.updated_at, model.updated_by) | |||
| Dim rsId : Set rsId = DAL.Query("SELECT @@IDENTITY AS NewID", Empty) | |||
| If Not rsId.EOF Then | |||
| If Not IsNull(rsId(0)) Then model.id = rsId(0) | |||
| End If | |||
| Destroy rsId | |||
| End Sub | |||
| Public Sub Update(model) | |||
| Dim sql : sql = "UPDATE [swim_lanes] SET [name]=?,[position]=?,[updated_at]=?,[updated_by]=? WHERE [id]=?" | |||
| DAL.Execute sql, Array(model.name, model.position, model.updated_at, model.updated_by, model.id) | |||
| End Sub | |||
| Public Sub UpdatePosition(id, position, updatedAt, updatedBy) | |||
| Dim sql : sql = "UPDATE [swim_lanes] SET [position]=?,[updated_at]=?,[updated_by]=? WHERE [id]=?" | |||
| DAL.Execute sql, Array(position, updatedAt, updatedBy, id) | |||
| End Sub | |||
| Public Sub Delete(id) | |||
| DAL.Execute "DELETE FROM [swim_lanes] WHERE [id]=?", Array(id) | |||
| End Sub | |||
| Public Sub DeleteByBoardId(boardId) | |||
| DAL.Execute "DELETE FROM [swim_lanes] WHERE [board_id]=?", Array(boardId) | |||
| End Sub | |||
| End Class | |||
| Dim swim_lanes_Repository__Singleton | |||
| Function swim_lanes_Repository() | |||
| If IsEmpty(swim_lanes_Repository__Singleton) Then | |||
| Set swim_lanes_Repository__Singleton = New swim_lanes_Repository_Class | |||
| End If | |||
| Set swim_lanes_Repository = swim_lanes_Repository__Singleton | |||
| End Function | |||
| %> | |||
| @@ -0,0 +1,48 @@ | |||
| <div class="row justify-content-center"> | |||
| <div class="col-md-6"> | |||
| <div class="d-flex align-items-center mb-4"> | |||
| <a href="/boards" class="btn btn-sm btn-outline-secondary me-3"> | |||
| <i class="bi bi-arrow-left"></i> | |||
| </a> | |||
| <h1 class="h3 mb-0">New Board</h1> | |||
| </div> | |||
| <div class="card shadow-sm"> | |||
| <div class="card-body"> | |||
| <form method="post" action="/boards"> | |||
| <div class="mb-3"> | |||
| <label for="name" class="form-label">Board Name</label> | |||
| <input type="text" class="form-control" id="name" name="name" | |||
| placeholder="e.g. Sprint 1" autofocus required /> | |||
| </div> | |||
| <div class="mb-3"> | |||
| <label class="form-label text-muted small">URL Slug <span class="text-secondary">(auto-generated)</span></label> | |||
| <div class="form-control bg-light text-muted" id="slug-preview" style="min-height:38px;"> </div> | |||
| </div> | |||
| <div class="d-flex gap-2"> | |||
| <button type="submit" class="btn btn-primary">Create Board</button> | |||
| <a href="/boards" class="btn btn-outline-secondary">Cancel</a> | |||
| </div> | |||
| </form> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <script> | |||
| (function () { | |||
| var nameEl = document.getElementById('name'); | |||
| var preview = document.getElementById('slug-preview'); | |||
| nameEl.addEventListener('input', function () { | |||
| preview.textContent = slugify(nameEl.value) || ' '; | |||
| }); | |||
| function slugify(s) { | |||
| return s.toLowerCase() | |||
| .replace(/&/g, 'and') | |||
| .replace(/[^a-z0-9\s-]/g, '') | |||
| .trim() | |||
| .replace(/[\s]+/g, '-') | |||
| .replace(/-+/g, '-'); | |||
| } | |||
| })(); | |||
| </script> | |||
| @@ -0,0 +1,41 @@ | |||
| <div class="row justify-content-center"> | |||
| <div class="col-md-6"> | |||
| <div class="d-flex align-items-center mb-4"> | |||
| <a href="/board/<%= H(board.slug) %>" class="btn btn-sm btn-outline-secondary me-3"> | |||
| <i class="bi bi-arrow-left"></i> | |||
| </a> | |||
| <h1 class="h3 mb-0">Edit Board</h1> | |||
| </div> | |||
| <div class="card shadow-sm mb-3"> | |||
| <div class="card-body"> | |||
| <form method="post" action="/board/<%= H(board.slug) %>/update"> | |||
| <div class="mb-3"> | |||
| <label for="name" class="form-label">Board Name</label> | |||
| <input type="text" class="form-control" id="name" name="name" | |||
| value="<%= H(board.name) %>" required autofocus /> | |||
| </div> | |||
| <div class="mb-3"> | |||
| <label class="form-label text-muted small">Current Slug</label> | |||
| <div class="form-control bg-light text-muted"><%= H(board.slug) %></div> | |||
| </div> | |||
| <div class="d-flex gap-2"> | |||
| <button type="submit" class="btn btn-primary">Save Changes</button> | |||
| <a href="/board/<%= H(board.slug) %>" class="btn btn-outline-secondary">Cancel</a> | |||
| </div> | |||
| </form> | |||
| </div> | |||
| </div> | |||
| <div class="card border-danger shadow-sm"> | |||
| <div class="card-body"> | |||
| <h6 class="text-danger">Delete Board</h6> | |||
| <p class="text-muted small mb-3">This will permanently delete the board, all its columns, swim lanes, and cards.</p> | |||
| <form method="post" action="/board/<%= H(board.slug) %>/delete" | |||
| onsubmit="return confirm('Delete this board and all its contents? This cannot be undone.')"> | |||
| <button type="submit" class="btn btn-danger btn-sm">Delete Board</button> | |||
| </form> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| @@ -0,0 +1,38 @@ | |||
| <% | |||
| Dim boardItem | |||
| %> | |||
| <div class="d-flex justify-content-between align-items-center mb-4"> | |||
| <h1 class="h3 mb-0">Boards</h1> | |||
| <a href="/boards/create" class="btn btn-primary"> | |||
| <i class="bi bi-plus-lg me-1"></i>New Board | |||
| </a> | |||
| </div> | |||
| <% If boards.Count = 0 Then %> | |||
| <div class="text-center py-5 text-muted"> | |||
| <i class="bi bi-kanban display-4 d-block mb-3"></i> | |||
| <p class="mb-3">No boards yet.</p> | |||
| <a href="/boards/create" class="btn btn-primary">Create your first board</a> | |||
| </div> | |||
| <% Else %> | |||
| <div class="row g-3"> | |||
| <% Dim boardIter : Set boardIter = boards.Iterator() %> | |||
| <% Do While boardIter.HasNext() %> | |||
| <% Set boardItem = boardIter.GetNext() %> | |||
| <div class="col-sm-6 col-md-4 col-lg-3"> | |||
| <div class="card h-100 shadow-sm"> | |||
| <div class="card-body d-flex flex-column"> | |||
| <h5 class="card-title"><%= H(boardItem.name) %></h5> | |||
| <p class="card-text text-muted small mb-3"><code><%= H(boardItem.slug) %></code></p> | |||
| <div class="mt-auto d-flex gap-2"> | |||
| <a href="/board/<%= H(boardItem.slug) %>" class="btn btn-sm btn-primary flex-grow-1">Open</a> | |||
| <a href="/board/<%= H(boardItem.slug) %>/edit" class="btn btn-sm btn-outline-secondary"> | |||
| <i class="bi bi-pencil"></i> | |||
| </a> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <% Loop %> | |||
| </div> | |||
| <% End If %> | |||
| @@ -0,0 +1,83 @@ | |||
| <!-- Settings slide-out panel --> | |||
| <div id="settings-overlay" class="kanban-settings-overlay d-none"></div> | |||
| <div id="settings-panel" class="kanban-settings-panel"> | |||
| <div class="settings-header d-flex justify-content-between align-items-center p-3 border-bottom"> | |||
| <h6 class="mb-0">Board Settings</h6> | |||
| <button class="btn btn-sm btn-outline-secondary" id="btn-close-settings"> | |||
| <i class="bi bi-x-lg"></i> | |||
| </button> | |||
| </div> | |||
| <div class="settings-body p-3"> | |||
| <!-- Columns section --> | |||
| <div class="mb-4"> | |||
| <div class="d-flex justify-content-between align-items-center mb-2"> | |||
| <strong class="small">Columns</strong> | |||
| <button class="btn btn-sm btn-outline-primary" id="btn-add-column"> | |||
| <i class="bi bi-plus"></i> Add | |||
| </button> | |||
| </div> | |||
| <div id="col-add-form" class="d-none mb-2"> | |||
| <div class="input-group input-group-sm"> | |||
| <input type="text" class="form-control" id="col-add-input" placeholder="Column name" /> | |||
| <button class="btn btn-primary" id="btn-col-add-save">Add</button> | |||
| <button class="btn btn-outline-secondary" id="btn-col-add-cancel">Cancel</button> | |||
| </div> | |||
| </div> | |||
| <ul class="list-group settings-sortable" id="col-list"> | |||
| <% Dim sCIdx | |||
| For sCIdx = 0 To colCount - 1 | |||
| Set colItem = colsArr(sCIdx) %> | |||
| <li class="list-group-item d-flex align-items-center gap-2 py-2" | |||
| data-id="<%= colItem.id %>"> | |||
| <i class="bi bi-grip-vertical text-muted drag-handle" style="cursor:grab;"></i> | |||
| <span class="flex-grow-1 col-label-text"><%= H(colItem.name) %></span> | |||
| <button class="btn btn-sm btn-link p-0 text-secondary btn-edit-col" title="Rename"> | |||
| <i class="bi bi-pencil"></i> | |||
| </button> | |||
| <button class="btn btn-sm btn-link p-0 text-danger btn-delete-col" title="Delete"> | |||
| <i class="bi bi-trash"></i> | |||
| </button> | |||
| </li> | |||
| <% Next %> | |||
| </ul> | |||
| </div> | |||
| <!-- Swim lanes section --> | |||
| <div class="mb-2"> | |||
| <div class="d-flex justify-content-between align-items-center mb-2"> | |||
| <strong class="small">Swim Lanes</strong> | |||
| <button class="btn btn-sm btn-outline-primary" id="btn-add-lane"> | |||
| <i class="bi bi-plus"></i> Add | |||
| </button> | |||
| </div> | |||
| <div id="lane-add-form" class="d-none mb-2"> | |||
| <div class="input-group input-group-sm"> | |||
| <input type="text" class="form-control" id="lane-add-input" placeholder="Swim lane name" /> | |||
| <button class="btn btn-primary" id="btn-lane-add-save">Add</button> | |||
| <button class="btn btn-outline-secondary" id="btn-lane-add-cancel">Cancel</button> | |||
| </div> | |||
| </div> | |||
| <ul class="list-group settings-sortable" id="lane-list"> | |||
| <% Dim sLIdx | |||
| For sLIdx = 0 To laneCount - 1 | |||
| Set laneItem = lanesArr(sLIdx) %> | |||
| <li class="list-group-item d-flex align-items-center gap-2 py-2" | |||
| data-id="<%= laneItem.id %>"> | |||
| <i class="bi bi-grip-vertical text-muted drag-handle" style="cursor:grab;"></i> | |||
| <span class="flex-grow-1 lane-label-text"><%= H(laneItem.name) %></span> | |||
| <button class="btn btn-sm btn-link p-0 text-secondary btn-edit-lane" title="Rename"> | |||
| <i class="bi bi-pencil"></i> | |||
| </button> | |||
| <button class="btn btn-sm btn-link p-0 text-danger btn-delete-lane" title="Delete"> | |||
| <i class="bi bi-trash"></i> | |||
| </button> | |||
| </li> | |||
| <% Next %> | |||
| </ul> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| @@ -0,0 +1,97 @@ | |||
| <!doctype html> | |||
| <% | |||
| Response.Charset = "utf-8" | |||
| Response.CodePage = 65001 | |||
| %> | |||
| <html lang="en"> | |||
| <head> | |||
| <meta charset="utf-8" /> | |||
| <title><%= H(board.name) %> — Kanban</title> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |||
| <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" /> | |||
| <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet" /> | |||
| <link href="/css/site.css?v=20260422b" rel="stylesheet" /> | |||
| <link href="/css/kanban.css?v=20260422b" rel="stylesheet" /> | |||
| </head> | |||
| <body class="kanban-page"> | |||
| <!-- Top bar --> | |||
| <nav class="navbar navbar-dark rk-topnav px-3 py-2"> | |||
| <div class="d-flex align-items-center gap-3 flex-grow-1 board-header-main"> | |||
| <a href="/boards" class="btn btn-sm btn-outline-secondary text-white border-secondary"> | |||
| <i class="bi bi-arrow-left"></i> | |||
| </a> | |||
| <span class="navbar-brand mb-0 h5 kanban-board-title"><%= H(board.name) %></span> | |||
| </div> | |||
| <div class="d-flex align-items-center gap-2 board-header-actions"> | |||
| <button class="btn btn-sm btn-outline-light" id="btn-add-card" | |||
| data-board-id="<%= board.id %>"> | |||
| <i class="bi bi-plus-lg me-1"></i>Add Card | |||
| </button> | |||
| <button class="btn btn-sm btn-outline-light" id="btn-settings" title="Board Settings"> | |||
| <i class="bi bi-gear"></i> | |||
| </button> | |||
| <a href="/auth/logout" class="btn btn-sm btn-outline-light" title="Sign Out"> | |||
| <i class="bi bi-box-arrow-right"></i> | |||
| </a> | |||
| </div> | |||
| </nav> | |||
| <!-- Kanban grid --> | |||
| <div class="kanban-wrapper"> | |||
| <div class="kanban-grid" id="kanban-grid"> | |||
| <!-- Corner cell --> | |||
| <div class="kanban-corner"></div> | |||
| <!-- Column headers --> | |||
| <% Dim vColIdx, vColItem | |||
| For vColIdx = 0 To colCount - 1 | |||
| Set vColItem = colsArr(vColIdx) %> | |||
| <div class="kanban-col-header" data-col-id="<%= vColItem.id %>"> | |||
| <span class="col-label"><%= H(vColItem.name) %></span> | |||
| </div> | |||
| <% Next %> | |||
| <!-- Swim lane rows --> | |||
| <% Dim vLaneIdx, vLaneItem | |||
| For vLaneIdx = 0 To laneCount - 1 | |||
| Set vLaneItem = lanesArr(vLaneIdx) %> | |||
| <!-- Lane header --> | |||
| <div class="kanban-lane-header" data-lane-id="<%= vLaneItem.id %>"> | |||
| <span class="lane-label"><%= H(vLaneItem.name) %></span> | |||
| </div> | |||
| <!-- Cells for this lane --> | |||
| <% For vColIdx = 0 To colCount - 1 | |||
| Set vColItem = colsArr(vColIdx) %> | |||
| <div class="kanban-cell" | |||
| data-col-id="<%= vColItem.id %>" | |||
| data-lane-id="<%= vLaneItem.id %>"> | |||
| </div> | |||
| <% Next %> | |||
| <% Next %> | |||
| </div> | |||
| </div> | |||
| <!--#include file="../Cards/_modal.asp" --> | |||
| <!--#include file="./SettingsPanel.asp" --> | |||
| <script> | |||
| var KANBAN = { | |||
| boardId: <%= board.id %>, | |||
| boardSlug: "<%= H(board.slug) %>", | |||
| cards: <%= cardsJson %> | |||
| }; | |||
| </script> | |||
| <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> | |||
| <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script> | |||
| <script src="/js/kanban-modal.js"></script> | |||
| <script src="/js/kanban-settings.js"></script> | |||
| <script src="/js/kanban-board.js"></script> | |||
| </body> | |||
| </html> | |||
| @@ -0,0 +1,32 @@ | |||
| <!-- Card create/edit modal --> | |||
| <div class="modal fade" id="cardModal" tabindex="-1" aria-labelledby="cardModalLabel" aria-hidden="true"> | |||
| <div class="modal-dialog modal-dialog-centered"> | |||
| <div class="modal-content"> | |||
| <div class="modal-header"> | |||
| <h5 class="modal-title" id="cardModalLabel">Add Card</h5> | |||
| <button type="button" class="btn-close" data-bs-dismiss="modal"></button> | |||
| </div> | |||
| <div class="modal-body"> | |||
| <input type="hidden" id="card-id" value="" /> | |||
| <input type="hidden" id="card-column-id" value="" /> | |||
| <input type="hidden" id="card-lane-id" value="" /> | |||
| <div class="mb-3"> | |||
| <label for="card-job-number" class="form-label">Job Number</label> | |||
| <input type="text" class="form-control" id="card-job-number" placeholder="e.g. 10042" /> | |||
| </div> | |||
| <div class="mb-3"> | |||
| <label for="card-job-name" class="form-label">Job Name</label> | |||
| <input type="text" class="form-control" id="card-job-name" placeholder="e.g. Smith Residence" /> | |||
| </div> | |||
| <div id="card-modal-error" class="alert alert-danger d-none"></div> | |||
| </div> | |||
| <div class="modal-footer"> | |||
| <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> | |||
| <button type="button" class="btn btn-danger d-none" id="btn-delete-card">Delete</button> | |||
| <button type="button" class="btn btn-primary" id="btn-save-card">Save</button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| @@ -27,31 +27,21 @@ If Len(pageTitle) = 0 Then pageTitle = "Classic ASP Starter Template" | |||
| <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" /> | |||
| <!-- Bootstrap Icons (optional) --> | |||
| <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet" /> | |||
| <!-- App Fonts --> | |||
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | |||
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |||
| <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Fraunces:opsz,wght@9..144,600&display=swap" rel="stylesheet" /> | |||
| <!-- App CSS --> | |||
| <link href="/css/site.css" rel="stylesheet" /> | |||
| <style> | |||
| body { | |||
| background-color: #f5f5f5; | |||
| } | |||
| .rk-navbar-brand { | |||
| font-weight: 600; | |||
| letter-spacing: 0.03em; | |||
| } | |||
| main.routekit-main { | |||
| padding-top: 1.5rem; | |||
| padding-bottom: 2rem; | |||
| } | |||
| </style> | |||
| </head> | |||
| <body> | |||
| <!-- Top navbar --> | |||
| <nav class="navbar navbar-expand-lg navbar-dark bg-dark"> | |||
| <nav class="navbar navbar-expand-lg navbar-dark rk-topnav"> | |||
| <div class="container-fluid"> | |||
| <a class="navbar-brand rk-navbar-brand" href="/"> | |||
| Classic ASP | |||
| <span class="text-secondary small">Starter</span> | |||
| RouteKit | |||
| <span class="small">Classic ASP</span> | |||
| </a> | |||
| <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#rkMainNav" aria-controls="rkMainNav" aria-expanded="false" aria-label="Toggle navigation"> | |||
| @@ -61,7 +51,10 @@ If Len(pageTitle) = 0 Then pageTitle = "Classic ASP Starter Template" | |||
| <div class="collapse navbar-collapse" id="rkMainNav"> | |||
| <ul class="navbar-nav me-auto mb-2 mb-lg-0"> | |||
| <li class="nav-item"> | |||
| <a class="nav-link" href="/home">Home</a> | |||
| <a class="nav-link <%= Active("home") %>" href="/home">Home</a> | |||
| </li> | |||
| <li class="nav-item"> | |||
| <a class="nav-link <%= Active("boards") %>" href="/boards">Boards</a> | |||
| </li> | |||
| </ul> | |||
| @@ -71,8 +64,20 @@ If Len(pageTitle) = 0 Then pageTitle = "Classic ASP Starter Template" | |||
| Set currentUser = KeycloakCurrentUser() | |||
| displayName = "" | |||
| If Not currentUser Is Nothing Then | |||
| If currentUser.Exists("preferred_username") Then displayName = CStr(currentUser.Item("preferred_username")) | |||
| If Len(displayName) = 0 And currentUser.Exists("email") Then displayName = CStr(currentUser.Item("email")) | |||
| On Error Resume Next | |||
| displayName = CStr(currentUser.Item("preferred_username")) | |||
| If Err.Number <> 0 Then | |||
| displayName = "" | |||
| Err.Clear | |||
| End If | |||
| If Len(displayName) = 0 Then | |||
| displayName = CStr(currentUser.Item("email")) | |||
| If Err.Number <> 0 Then | |||
| displayName = "" | |||
| Err.Clear | |||
| End If | |||
| End If | |||
| On Error GoTo 0 | |||
| End If | |||
| If Len(displayName) = 0 Then displayName = "User" | |||
| %> | |||
| @@ -137,7 +137,7 @@ | |||
| <add name="Clr2IntegratedAppPool" managedRuntimeVersion="v2.0" managedPipelineMode="Integrated" CLRConfigFile="%IIS_BIN%\config\templates\PersonalWebServer\aspnet.config" autoStart="true" /> | |||
| <add name="Clr2ClassicAppPool" managedRuntimeVersion="v2.0" managedPipelineMode="Classic" CLRConfigFile="%IIS_BIN%\config\templates\PersonalWebServer\aspnet.config" autoStart="true" /> | |||
| <add name="UnmanagedClassicAppPool" managedRuntimeVersion="" managedPipelineMode="Classic" autoStart="true" /> | |||
| <add name="IISExpressAppPool" managedRuntimeVersion="v4.0" managedPipelineMode="Integrated" CLRConfigFile="%IIS_BIN%\config\templates\PersonalWebServer\aspnet.config" autoStart="true" /> | |||
| <add name="IISExpressAppPool" managedRuntimeVersion="v4.0" managedPipelineMode="Integrated" CLRConfigFile="%IIS_BIN%\config\templates\PersonalWebServer\aspnet.config" autoStart="true" enable32BitAppOnWin64="true" /> | |||
| <applicationPoolDefaults managedRuntimeVersion="v4.0"> | |||
| <processModel loadUserProfile="true" setProfileEnvironment="false" /> | |||
| </applicationPoolDefaults> | |||
| @@ -16,6 +16,10 @@ Class ControllerRegistry_Class | |||
| RegisterController "homecontroller" | |||
| RegisterController "errorcontroller" | |||
| RegisterController "authcontroller" | |||
| RegisterController "boardscontroller" | |||
| RegisterController "columnscontroller" | |||
| RegisterController "swimlanescontroller" | |||
| RegisterController "cardscontroller" | |||
| End Sub | |||
| Private Sub Class_Terminate() | |||
| @@ -0,0 +1,24 @@ | |||
| <% | |||
| '======================================================================================================================= | |||
| ' MIGRATION: create_boards | |||
| '======================================================================================================================= | |||
| Sub Migration_Up(migration) | |||
| migration.ExecuteSQL _ | |||
| "CREATE TABLE [boards] (" & _ | |||
| "[id] AUTOINCREMENT PRIMARY KEY, " & _ | |||
| "[name] VARCHAR(255) NOT NULL, " & _ | |||
| "[slug] VARCHAR(255) NOT NULL, " & _ | |||
| "[created_at] DATETIME, " & _ | |||
| "[created_by] VARCHAR(255), " & _ | |||
| "[updated_at] DATETIME, " & _ | |||
| "[updated_by] VARCHAR(255)" & _ | |||
| ")" | |||
| migration.ExecuteSQL "CREATE INDEX [idx_boards_slug] ON [boards] ([slug])" | |||
| End Sub | |||
| Sub Migration_Down(migration) | |||
| migration.ExecuteSQL "DROP TABLE [boards]" | |||
| End Sub | |||
| %> | |||
| @@ -0,0 +1,25 @@ | |||
| <% | |||
| '======================================================================================================================= | |||
| ' MIGRATION: create_board_columns | |||
| '======================================================================================================================= | |||
| Sub Migration_Up(migration) | |||
| migration.ExecuteSQL _ | |||
| "CREATE TABLE [board_columns] (" & _ | |||
| "[id] AUTOINCREMENT PRIMARY KEY, " & _ | |||
| "[board_id] INTEGER NOT NULL, " & _ | |||
| "[name] VARCHAR(255) NOT NULL, " & _ | |||
| "[position] INTEGER NOT NULL, " & _ | |||
| "[created_at] DATETIME, " & _ | |||
| "[created_by] VARCHAR(255), " & _ | |||
| "[updated_at] DATETIME, " & _ | |||
| "[updated_by] VARCHAR(255)" & _ | |||
| ")" | |||
| migration.ExecuteSQL "CREATE INDEX [idx_board_columns_board_id] ON [board_columns] ([board_id])" | |||
| End Sub | |||
| Sub Migration_Down(migration) | |||
| migration.ExecuteSQL "DROP TABLE [board_columns]" | |||
| End Sub | |||
| %> | |||
| @@ -0,0 +1,25 @@ | |||
| <% | |||
| '======================================================================================================================= | |||
| ' MIGRATION: create_swim_lanes | |||
| '======================================================================================================================= | |||
| Sub Migration_Up(migration) | |||
| migration.ExecuteSQL _ | |||
| "CREATE TABLE [swim_lanes] (" & _ | |||
| "[id] AUTOINCREMENT PRIMARY KEY, " & _ | |||
| "[board_id] INTEGER NOT NULL, " & _ | |||
| "[name] VARCHAR(255) NOT NULL, " & _ | |||
| "[position] INTEGER NOT NULL, " & _ | |||
| "[created_at] DATETIME, " & _ | |||
| "[created_by] VARCHAR(255), " & _ | |||
| "[updated_at] DATETIME, " & _ | |||
| "[updated_by] VARCHAR(255)" & _ | |||
| ")" | |||
| migration.ExecuteSQL "CREATE INDEX [idx_swim_lanes_board_id] ON [swim_lanes] ([board_id])" | |||
| End Sub | |||
| Sub Migration_Down(migration) | |||
| migration.ExecuteSQL "DROP TABLE [swim_lanes]" | |||
| End Sub | |||
| %> | |||
| @@ -0,0 +1,30 @@ | |||
| <% | |||
| '======================================================================================================================= | |||
| ' MIGRATION: create_cards | |||
| '======================================================================================================================= | |||
| Sub Migration_Up(migration) | |||
| migration.ExecuteSQL _ | |||
| "CREATE TABLE [cards] (" & _ | |||
| "[id] AUTOINCREMENT PRIMARY KEY, " & _ | |||
| "[board_id] INTEGER NOT NULL, " & _ | |||
| "[column_id] INTEGER NOT NULL, " & _ | |||
| "[swim_lane_id] INTEGER NOT NULL, " & _ | |||
| "[job_number] VARCHAR(255), " & _ | |||
| "[job_name] VARCHAR(255), " & _ | |||
| "[position] INTEGER NOT NULL, " & _ | |||
| "[created_at] DATETIME, " & _ | |||
| "[created_by] VARCHAR(255), " & _ | |||
| "[updated_at] DATETIME, " & _ | |||
| "[updated_by] VARCHAR(255)" & _ | |||
| ")" | |||
| migration.ExecuteSQL "CREATE INDEX [idx_cards_board_id] ON [cards] ([board_id])" | |||
| migration.ExecuteSQL "CREATE INDEX [idx_cards_column_id] ON [cards] ([column_id])" | |||
| migration.ExecuteSQL "CREATE INDEX [idx_cards_swim_lane_id] ON [cards] ([swim_lane_id])" | |||
| End Sub | |||
| Sub Migration_Down(migration) | |||
| migration.ExecuteSQL "DROP TABLE [cards]" | |||
| End Sub | |||
| %> | |||
| @@ -0,0 +1,155 @@ | |||
| <% | |||
| '======================================================================================================================= | |||
| ' MIGRATION: seed_sample_boards_and_layout | |||
| '======================================================================================================================= | |||
| Sub Migration_Up(migration) | |||
| Call EnsureBoardWithLayout(migration, "Website Redesign", "website-redesign", "sample.seed") | |||
| Call EnsureBoardWithLayout(migration, "Q3 Launch Plan", "q3-launch-plan", "sample.seed") | |||
| End Sub | |||
| Sub Migration_Down(migration) | |||
| Call DbExecute(migration, "DELETE FROM [swim_lanes] WHERE [created_by] = ?", Array("sample.seed")) | |||
| Call DbExecute(migration, "DELETE FROM [board_columns] WHERE [created_by] = ?", Array("sample.seed")) | |||
| Call DbExecute(migration, "DELETE FROM [boards] WHERE [created_by] = ?", Array("sample.seed")) | |||
| End Sub | |||
| Private Sub EnsureBoardWithLayout(migration, boardName, boardSlug, seedUser) | |||
| Dim boardId | |||
| boardId = GetBoardIdBySlug(migration, boardSlug) | |||
| If boardId = 0 Then | |||
| Call DbExecute(migration, _ | |||
| "INSERT INTO [boards] ([name],[slug],[created_at],[created_by],[updated_at],[updated_by]) VALUES (?,?,?,?,?,?)", _ | |||
| Array(boardName, boardSlug, Now(), seedUser, Now(), seedUser)) | |||
| boardId = GetBoardIdBySlug(migration, boardSlug) | |||
| End If | |||
| If boardId > 0 Then | |||
| Call EnsureColumn(migration, boardId, "Backlog", 0, seedUser) | |||
| Call EnsureColumn(migration, boardId, "In Progress", 1, seedUser) | |||
| Call EnsureColumn(migration, boardId, "Review", 2, seedUser) | |||
| Call EnsureColumn(migration, boardId, "Done", 3, seedUser) | |||
| Call EnsureSwimLane(migration, boardId, "Expedite", 0, seedUser) | |||
| Call EnsureSwimLane(migration, boardId, "Standard", 1, seedUser) | |||
| Call EnsureSwimLane(migration, boardId, "Blocked", 2, seedUser) | |||
| End If | |||
| End Sub | |||
| Private Sub EnsureColumn(migration, boardId, columnName, columnPosition, seedUser) | |||
| If GetColumnIdByName(migration, boardId, columnName) = 0 Then | |||
| Call DbExecute(migration, _ | |||
| "INSERT INTO [board_columns] ([board_id],[name],[position],[created_at],[created_by],[updated_at],[updated_by]) VALUES (?,?,?,?,?,?,?)", _ | |||
| Array(boardId, columnName, columnPosition, Now(), seedUser, Now(), seedUser)) | |||
| End If | |||
| End Sub | |||
| Private Sub EnsureSwimLane(migration, boardId, laneName, lanePosition, seedUser) | |||
| If GetSwimLaneIdByName(migration, boardId, laneName) = 0 Then | |||
| Call DbExecute(migration, _ | |||
| "INSERT INTO [swim_lanes] ([board_id],[name],[position],[created_at],[created_by],[updated_at],[updated_by]) VALUES (?,?,?,?,?,?,?)", _ | |||
| Array(boardId, laneName, lanePosition, Now(), seedUser, Now(), seedUser)) | |||
| End If | |||
| End Sub | |||
| Private Function GetBoardIdBySlug(migration, boardSlug) | |||
| Dim rs | |||
| Set rs = DbQuery(migration, "SELECT TOP 1 [id] FROM [boards] WHERE [slug] = ?", Array(boardSlug)) | |||
| If rs.EOF Then | |||
| GetBoardIdBySlug = 0 | |||
| Else | |||
| GetBoardIdBySlug = CLng(rs(0)) | |||
| End If | |||
| Call CloseRecordset(rs) | |||
| End Function | |||
| Private Function GetColumnIdByName(migration, boardId, columnName) | |||
| Dim rs | |||
| Set rs = DbQuery(migration, "SELECT TOP 1 [id] FROM [board_columns] WHERE [board_id] = ? AND [name] = ?", Array(boardId, columnName)) | |||
| If rs.EOF Then | |||
| GetColumnIdByName = 0 | |||
| Else | |||
| GetColumnIdByName = CLng(rs(0)) | |||
| End If | |||
| Call CloseRecordset(rs) | |||
| End Function | |||
| Private Function GetSwimLaneIdByName(migration, boardId, laneName) | |||
| Dim rs | |||
| Set rs = DbQuery(migration, "SELECT TOP 1 [id] FROM [swim_lanes] WHERE [board_id] = ? AND [name] = ?", Array(boardId, laneName)) | |||
| If rs.EOF Then | |||
| GetSwimLaneIdByName = 0 | |||
| Else | |||
| GetSwimLaneIdByName = CLng(rs(0)) | |||
| End If | |||
| Call CloseRecordset(rs) | |||
| End Function | |||
| Private Sub CloseRecordset(ByRef rs) | |||
| If IsObject(rs) Then | |||
| If Not rs Is Nothing Then | |||
| On Error Resume Next | |||
| rs.Close | |||
| Set rs = Nothing | |||
| On Error GoTo 0 | |||
| End If | |||
| End If | |||
| End Sub | |||
| Private Sub DbExecute(migration, sql, params) | |||
| On Error Resume Next | |||
| migration.DB.Execute sql, params | |||
| If Err.Number = 0 Then | |||
| On Error GoTo 0 | |||
| Exit Sub | |||
| End If | |||
| Err.Clear | |||
| On Error GoTo 0 | |||
| Dim conn, cmd | |||
| Set conn = migration.Connection | |||
| Set cmd = CreateObject("ADODB.Command") | |||
| Set cmd.ActiveConnection = conn | |||
| cmd.CommandText = sql | |||
| If IsArray(params) Then | |||
| cmd.Execute , params | |||
| ElseIf IsEmpty(params) Then | |||
| cmd.Execute | |||
| Else | |||
| cmd.Execute , Array(params) | |||
| End If | |||
| Set cmd = Nothing | |||
| End Sub | |||
| Private Function DbQuery(migration, sql, params) | |||
| On Error Resume Next | |||
| Set DbQuery = migration.DB.Query(sql, params) | |||
| If Err.Number = 0 Then | |||
| On Error GoTo 0 | |||
| Exit Function | |||
| End If | |||
| Err.Clear | |||
| On Error GoTo 0 | |||
| Dim conn, cmd, rs | |||
| Set conn = migration.Connection | |||
| Set cmd = CreateObject("ADODB.Command") | |||
| Set cmd.ActiveConnection = conn | |||
| cmd.CommandText = sql | |||
| If IsArray(params) Then | |||
| Set rs = cmd.Execute(, params) | |||
| ElseIf IsEmpty(params) Then | |||
| Set rs = cmd.Execute() | |||
| Else | |||
| Set rs = cmd.Execute(, Array(params)) | |||
| End If | |||
| Set DbQuery = rs | |||
| Set cmd = Nothing | |||
| End Function | |||
| %> | |||
| @@ -0,0 +1,170 @@ | |||
| <% | |||
| '======================================================================================================================= | |||
| ' MIGRATION: seed_sample_cards | |||
| '======================================================================================================================= | |||
| Sub Migration_Up(migration) | |||
| Call SeedBoardCards(migration, "website-redesign", "sample.seed") | |||
| Call SeedBoardCards(migration, "q3-launch-plan", "sample.seed") | |||
| End Sub | |||
| Sub Migration_Down(migration) | |||
| Call DbExecute(migration, "DELETE FROM [cards] WHERE [created_by] = ?", Array("sample.seed")) | |||
| End Sub | |||
| Private Sub SeedBoardCards(migration, boardSlug, seedUser) | |||
| Dim boardId | |||
| boardId = GetBoardIdBySlug(migration, boardSlug) | |||
| If boardId = 0 Then Exit Sub | |||
| Dim colBacklog, colInProgress, colReview, colDone | |||
| Dim laneStandard, laneExpedite, laneBlocked | |||
| colBacklog = GetColumnIdByName(migration, boardId, "Backlog") | |||
| colInProgress = GetColumnIdByName(migration, boardId, "In Progress") | |||
| colReview = GetColumnIdByName(migration, boardId, "Review") | |||
| colDone = GetColumnIdByName(migration, boardId, "Done") | |||
| laneStandard = GetSwimLaneIdByName(migration, boardId, "Standard") | |||
| laneExpedite = GetSwimLaneIdByName(migration, boardId, "Expedite") | |||
| laneBlocked = GetSwimLaneIdByName(migration, boardId, "Blocked") | |||
| If colBacklog > 0 And laneStandard > 0 Then | |||
| Call EnsureCard(migration, boardId, colBacklog, laneStandard, "JOB-1001", "Collect requirements", 0, seedUser) | |||
| End If | |||
| If colInProgress > 0 And laneExpedite > 0 Then | |||
| Call EnsureCard(migration, boardId, colInProgress, laneExpedite, "JOB-1002", "Design homepage wireframe", 0, seedUser) | |||
| End If | |||
| If colReview > 0 And laneStandard > 0 Then | |||
| Call EnsureCard(migration, boardId, colReview, laneStandard, "JOB-1003", "Stakeholder content review", 0, seedUser) | |||
| End If | |||
| If colDone > 0 And laneStandard > 0 Then | |||
| Call EnsureCard(migration, boardId, colDone, laneStandard, "JOB-1004", "Set up board conventions", 0, seedUser) | |||
| End If | |||
| If colBacklog > 0 And laneBlocked > 0 Then | |||
| Call EnsureCard(migration, boardId, colBacklog, laneBlocked, "JOB-1005", "Await vendor assets", 0, seedUser) | |||
| End If | |||
| End Sub | |||
| Private Sub EnsureCard(migration, boardId, columnId, swimLaneId, jobNumber, jobName, cardPosition, seedUser) | |||
| If GetCardIdByJobNumber(migration, boardId, jobNumber) = 0 Then | |||
| Call DbExecute(migration, _ | |||
| "INSERT INTO [cards] ([board_id],[column_id],[swim_lane_id],[job_number],[job_name],[position],[created_at],[created_by],[updated_at],[updated_by]) VALUES (?,?,?,?,?,?,?,?,?,?)", _ | |||
| Array(boardId, columnId, swimLaneId, jobNumber, jobName, cardPosition, Now(), seedUser, Now(), seedUser)) | |||
| End If | |||
| End Sub | |||
| Private Function GetCardIdByJobNumber(migration, boardId, jobNumber) | |||
| Dim rs | |||
| Set rs = DbQuery(migration, "SELECT TOP 1 [id] FROM [cards] WHERE [board_id] = ? AND [job_number] = ?", Array(boardId, jobNumber)) | |||
| If rs.EOF Then | |||
| GetCardIdByJobNumber = 0 | |||
| Else | |||
| GetCardIdByJobNumber = CLng(rs(0)) | |||
| End If | |||
| Call CloseRecordset(rs) | |||
| End Function | |||
| Private Function GetBoardIdBySlug(migration, boardSlug) | |||
| Dim rs | |||
| Set rs = DbQuery(migration, "SELECT TOP 1 [id] FROM [boards] WHERE [slug] = ?", Array(boardSlug)) | |||
| If rs.EOF Then | |||
| GetBoardIdBySlug = 0 | |||
| Else | |||
| GetBoardIdBySlug = CLng(rs(0)) | |||
| End If | |||
| Call CloseRecordset(rs) | |||
| End Function | |||
| Private Function GetColumnIdByName(migration, boardId, columnName) | |||
| Dim rs | |||
| Set rs = DbQuery(migration, "SELECT TOP 1 [id] FROM [board_columns] WHERE [board_id] = ? AND [name] = ?", Array(boardId, columnName)) | |||
| If rs.EOF Then | |||
| GetColumnIdByName = 0 | |||
| Else | |||
| GetColumnIdByName = CLng(rs(0)) | |||
| End If | |||
| Call CloseRecordset(rs) | |||
| End Function | |||
| Private Function GetSwimLaneIdByName(migration, boardId, laneName) | |||
| Dim rs | |||
| Set rs = DbQuery(migration, "SELECT TOP 1 [id] FROM [swim_lanes] WHERE [board_id] = ? AND [name] = ?", Array(boardId, laneName)) | |||
| If rs.EOF Then | |||
| GetSwimLaneIdByName = 0 | |||
| Else | |||
| GetSwimLaneIdByName = CLng(rs(0)) | |||
| End If | |||
| Call CloseRecordset(rs) | |||
| End Function | |||
| Private Sub CloseRecordset(ByRef rs) | |||
| If IsObject(rs) Then | |||
| If Not rs Is Nothing Then | |||
| On Error Resume Next | |||
| rs.Close | |||
| Set rs = Nothing | |||
| On Error GoTo 0 | |||
| End If | |||
| End If | |||
| End Sub | |||
| Private Sub DbExecute(migration, sql, params) | |||
| On Error Resume Next | |||
| migration.DB.Execute sql, params | |||
| If Err.Number = 0 Then | |||
| On Error GoTo 0 | |||
| Exit Sub | |||
| End If | |||
| Err.Clear | |||
| On Error GoTo 0 | |||
| Dim conn, cmd | |||
| Set conn = migration.Connection | |||
| Set cmd = CreateObject("ADODB.Command") | |||
| Set cmd.ActiveConnection = conn | |||
| cmd.CommandText = sql | |||
| If IsArray(params) Then | |||
| cmd.Execute , params | |||
| ElseIf IsEmpty(params) Then | |||
| cmd.Execute | |||
| Else | |||
| cmd.Execute , Array(params) | |||
| End If | |||
| Set cmd = Nothing | |||
| End Sub | |||
| Private Function DbQuery(migration, sql, params) | |||
| On Error Resume Next | |||
| Set DbQuery = migration.DB.Query(sql, params) | |||
| If Err.Number = 0 Then | |||
| On Error GoTo 0 | |||
| Exit Function | |||
| End If | |||
| Err.Clear | |||
| On Error GoTo 0 | |||
| Dim conn, cmd, rs | |||
| Set conn = migration.Connection | |||
| Set cmd = CreateObject("ADODB.Command") | |||
| Set cmd.ActiveConnection = conn | |||
| cmd.CommandText = sql | |||
| If IsArray(params) Then | |||
| Set rs = cmd.Execute(, params) | |||
| ElseIf IsEmpty(params) Then | |||
| Set rs = cmd.Execute() | |||
| Else | |||
| Set rs = cmd.Execute(, Array(params)) | |||
| End If | |||
| Set DbQuery = rs | |||
| Set cmd = Nothing | |||
| End Function | |||
| %> | |||
| @@ -11,6 +11,33 @@ | |||
| router.AddRoute "GET", "/auth/callback", "AuthController", "Callback" | |||
| router.AddRoute "GET", "/auth/logout", "AuthController", "Logout" | |||
| ' Board routes | |||
| router.AddRoute "GET", "/boards", "BoardsController", "Index" | |||
| router.AddRoute "GET", "/boards/create", "BoardsController", "Create" | |||
| router.AddRoute "POST", "/boards", "BoardsController", "Store" | |||
| router.AddRoute "GET", "/board/:slug", "BoardsController", "Show" | |||
| router.AddRoute "GET", "/board/:slug/edit", "BoardsController", "Edit" | |||
| router.AddRoute "POST", "/board/:slug/update", "BoardsController", "Update" | |||
| router.AddRoute "POST", "/board/:slug/delete", "BoardsController", "Destroy" | |||
| ' Column routes (JSON) | |||
| router.AddRoute "POST", "/columns", "ColumnsController", "Store" | |||
| router.AddRoute "POST", "/columns/reorder", "ColumnsController", "Reorder" | |||
| router.AddRoute "POST", "/columns/:id", "ColumnsController", "Update" | |||
| router.AddRoute "POST", "/columns/:id/delete", "ColumnsController", "Destroy" | |||
| ' Swim lane routes (JSON) | |||
| router.AddRoute "POST", "/swimlanes", "SwimLanesController", "Store" | |||
| router.AddRoute "POST", "/swimlanes/reorder", "SwimLanesController", "Reorder" | |||
| router.AddRoute "POST", "/swimlanes/:id", "SwimLanesController", "Update" | |||
| router.AddRoute "POST", "/swimlanes/:id/delete", "SwimLanesController", "Destroy" | |||
| ' Card routes (JSON) | |||
| router.AddRoute "POST", "/cards", "CardsController", "Store" | |||
| router.AddRoute "POST", "/cards/:id", "CardsController", "Update" | |||
| router.AddRoute "POST", "/cards/:id/move", "CardsController", "Move" | |||
| router.AddRoute "POST", "/cards/:id/delete", "CardsController", "Destroy" | |||
| router.AddRoute "GET", "/404", "ErrorController", "NotFound" | |||
| ' Dispatch the request (resolves route and executes controller action) | |||
| @@ -0,0 +1,357 @@ | |||
| /* Kanban board theme aligned with public/css/site.css */ | |||
| html, | |||
| body.kanban-page { | |||
| height: 100%; | |||
| } | |||
| body.kanban-page { | |||
| margin: 0; | |||
| overflow: hidden; | |||
| background: | |||
| radial-gradient(75rem 35rem at -12% -18%, #dae8ff 0%, transparent 44%), | |||
| radial-gradient(68rem 32rem at 115% -16%, #d8f3ff 0%, transparent 40%), | |||
| linear-gradient(180deg, #eef4ff 0%, #f4f8ff 58%, #f3f7ff 100%); | |||
| } | |||
| /* Top bar */ | |||
| body.kanban-page .navbar { | |||
| position: sticky; | |||
| top: 0; | |||
| z-index: 1000; | |||
| border-bottom: 1px solid rgba(255, 255, 255, 0.18); | |||
| backdrop-filter: blur(9px); | |||
| background: linear-gradient(120deg, #102241 0%, #173a72 56%, #1c4c90 100%); | |||
| box-shadow: 0 8px 24px rgba(8, 20, 48, 0.26); | |||
| } | |||
| body.kanban-page .navbar-brand { | |||
| color: #f4f8ff !important; | |||
| font-family: "Fraunces", Georgia, serif; | |||
| letter-spacing: -0.01em; | |||
| } | |||
| .board-header-main { | |||
| min-width: 0; | |||
| } | |||
| .board-header-actions { | |||
| flex-shrink: 0; | |||
| } | |||
| .kanban-board-title { | |||
| display: block; | |||
| min-width: 0; | |||
| max-width: 100%; | |||
| overflow: hidden; | |||
| text-overflow: ellipsis; | |||
| white-space: nowrap; | |||
| font-size: clamp(1rem, 0.6vw + 0.95rem, 1.28rem); | |||
| } | |||
| body.kanban-page .navbar .btn-outline-light, | |||
| body.kanban-page .navbar .btn-outline-secondary { | |||
| border-color: rgba(213, 230, 255, 0.55) !important; | |||
| color: #eef5ff !important; | |||
| background: rgba(255, 255, 255, 0.06); | |||
| } | |||
| body.kanban-page .navbar .btn-outline-light:hover, | |||
| body.kanban-page .navbar .btn-outline-secondary:hover { | |||
| background: rgba(255, 255, 255, 0.16); | |||
| border-color: rgba(234, 243, 255, 0.85) !important; | |||
| } | |||
| /* Board wrapper */ | |||
| .kanban-wrapper { | |||
| height: calc(100vh - 65px); | |||
| overflow: auto; | |||
| padding: 0.9rem 1rem 1.1rem; | |||
| -webkit-overflow-scrolling: touch; | |||
| scroll-behavior: smooth; | |||
| touch-action: pan-x pan-y; | |||
| } | |||
| .kanban-grid { | |||
| display: grid; | |||
| min-width: max-content; | |||
| border: 1px solid var(--line, #d9e3f5); | |||
| border-radius: 14px; | |||
| background: rgba(255, 255, 255, 0.72); | |||
| box-shadow: 0 12px 34px rgba(22, 48, 92, 0.12); | |||
| overflow: hidden; | |||
| } | |||
| /* Sticky corner and headers */ | |||
| .kanban-corner { | |||
| position: sticky; | |||
| top: 0; | |||
| left: 0; | |||
| z-index: 40; | |||
| min-width: 240px; | |||
| min-height: 56px; | |||
| background: linear-gradient(135deg, #173864 0%, #1e4d8f 100%); | |||
| border-right: 1px solid rgba(255, 255, 255, 0.15); | |||
| border-bottom: 1px solid rgba(255, 255, 255, 0.15); | |||
| } | |||
| .kanban-col-header { | |||
| position: sticky; | |||
| top: 0; | |||
| z-index: 30; | |||
| min-width: 230px; | |||
| padding: 0.75rem 0.88rem; | |||
| color: #eff5ff; | |||
| font-size: clamp(0.68rem, 0.16vw + 0.65rem, 0.78rem); | |||
| font-weight: 700; | |||
| text-transform: uppercase; | |||
| letter-spacing: 0.06em; | |||
| white-space: normal; | |||
| line-height: 1.22; | |||
| word-break: break-word; | |||
| overflow-wrap: anywhere; | |||
| background: linear-gradient(120deg, #173864 0%, #1e4d8f 100%); | |||
| border-left: 1px solid rgba(255, 255, 255, 0.16); | |||
| } | |||
| .kanban-lane-header { | |||
| position: sticky; | |||
| left: 0; | |||
| z-index: 20; | |||
| min-width: 240px; | |||
| padding: 0.85rem 0.82rem; | |||
| font-size: clamp(0.7rem, 0.14vw + 0.66rem, 0.78rem); | |||
| font-weight: 700; | |||
| color: #2a3a58; | |||
| background: linear-gradient(180deg, #f6faff 0%, #edf3ff 100%); | |||
| border-top: 1px solid #d7e1f5; | |||
| border-right: 1px solid #d4deef; | |||
| } | |||
| .kanban-lane-header .lane-label { | |||
| display: inline-block; | |||
| max-width: 100%; | |||
| white-space: normal; | |||
| line-height: 1.2; | |||
| word-break: break-word; | |||
| overflow-wrap: anywhere; | |||
| } | |||
| /* Cells */ | |||
| .kanban-cell { | |||
| min-width: 230px; | |||
| min-height: 132px; | |||
| padding: 0.56rem; | |||
| border-top: 1px solid #dce5f4; | |||
| border-left: 1px solid #dce5f4; | |||
| display: flex; | |||
| flex-direction: column; | |||
| gap: 0.45rem; | |||
| vertical-align: top; | |||
| background: | |||
| linear-gradient(180deg, rgba(255, 255, 255, 0.88) 0%, rgba(244, 248, 255, 0.93) 100%); | |||
| } | |||
| .kanban-cell.drag-over { | |||
| background: linear-gradient(180deg, #e9f2ff 0%, #deecff 100%); | |||
| box-shadow: inset 0 0 0 2px rgba(19, 99, 223, 0.24); | |||
| } | |||
| /* Cards */ | |||
| .kanban-card { | |||
| border: 1px solid #d5e1f6; | |||
| border-radius: 12px; | |||
| background: #ffffff; | |||
| padding: 0.56rem 0.62rem; | |||
| cursor: pointer; | |||
| user-select: none; | |||
| box-shadow: 0 4px 12px rgba(16, 44, 90, 0.08); | |||
| transition: transform 120ms ease, box-shadow 140ms ease, border-color 120ms ease; | |||
| touch-action: manipulation; | |||
| } | |||
| .kanban-card:hover { | |||
| transform: translateY(-1px); | |||
| border-color: #8eb0ea; | |||
| box-shadow: 0 10px 22px rgba(16, 44, 90, 0.14); | |||
| } | |||
| .kanban-card.sortable-ghost { | |||
| opacity: 0.38; | |||
| } | |||
| .kanban-card.sortable-chosen { | |||
| border-color: #4d87e2; | |||
| box-shadow: 0 14px 30px rgba(17, 46, 94, 0.22); | |||
| } | |||
| .card-job-number { | |||
| display: inline-block; | |||
| margin-bottom: 0.36rem; | |||
| padding: 0.08rem 0.42rem; | |||
| border-radius: 999px; | |||
| font-size: 0.66rem; | |||
| font-weight: 800; | |||
| letter-spacing: 0.07em; | |||
| text-transform: uppercase; | |||
| color: #0e4fae; | |||
| background: #e7f0ff; | |||
| } | |||
| .card-job-name { | |||
| color: #1f2b43; | |||
| font-size: 0.86rem; | |||
| line-height: 1.32; | |||
| } | |||
| /* Settings panel */ | |||
| .kanban-settings-overlay { | |||
| position: fixed; | |||
| inset: 0; | |||
| z-index: 1040; | |||
| background: rgba(13, 25, 48, 0.42); | |||
| backdrop-filter: blur(2px); | |||
| } | |||
| .kanban-settings-panel { | |||
| position: fixed; | |||
| top: 0; | |||
| right: 0; | |||
| width: min(420px, 94vw); | |||
| max-width: 100vw; | |||
| height: 100%; | |||
| z-index: 1050; | |||
| background: linear-gradient(180deg, #f7fbff 0%, #f1f6ff 100%); | |||
| border-left: 1px solid #d8e2f2; | |||
| box-shadow: -8px 0 28px rgba(19, 40, 81, 0.2); | |||
| display: flex; | |||
| flex-direction: column; | |||
| overflow: hidden; | |||
| transform: translateX(104%); | |||
| transition: transform 180ms cubic-bezier(0.2, 0.7, 0.2, 1); | |||
| will-change: transform; | |||
| } | |||
| .kanban-settings-panel.open { | |||
| transform: translateX(0); | |||
| } | |||
| .settings-header { | |||
| background: rgba(255, 255, 255, 0.7); | |||
| border-bottom-color: #dce6f5 !important; | |||
| } | |||
| .settings-header h6 { | |||
| letter-spacing: -0.01em; | |||
| } | |||
| .settings-body { | |||
| flex: 1; | |||
| overflow-y: auto; | |||
| } | |||
| .settings-body .list-group-item { | |||
| border-color: #d7e2f4; | |||
| border-radius: 10px !important; | |||
| margin-bottom: 0.45rem; | |||
| background: #ffffff; | |||
| box-shadow: 0 3px 9px rgba(25, 45, 84, 0.05); | |||
| } | |||
| .settings-sortable .drag-handle { | |||
| color: #8fa1bf !important; | |||
| } | |||
| .settings-sortable .drag-handle:hover { | |||
| color: var(--brand, #1363df) !important; | |||
| } | |||
| .inline-rename { | |||
| height: auto; | |||
| font-size: 0.84rem; | |||
| padding: 0.23rem 0.45rem; | |||
| } | |||
| /* Modal polish */ | |||
| #cardModal .modal-content { | |||
| border: 1px solid #d7e2f5; | |||
| border-radius: 14px; | |||
| box-shadow: 0 22px 48px rgba(18, 43, 83, 0.22); | |||
| } | |||
| #cardModal .modal-header { | |||
| border-bottom-color: #dfe8f7; | |||
| background: #f7fbff; | |||
| } | |||
| #cardModal .modal-footer { | |||
| border-top-color: #dfe8f7; | |||
| background: #fbfdff; | |||
| } | |||
| /* Scrollbars */ | |||
| .kanban-wrapper::-webkit-scrollbar, | |||
| .settings-body::-webkit-scrollbar { | |||
| width: 10px; | |||
| height: 10px; | |||
| } | |||
| .kanban-wrapper::-webkit-scrollbar-track, | |||
| .settings-body::-webkit-scrollbar-track { | |||
| background: #eaf0fb; | |||
| } | |||
| .kanban-wrapper::-webkit-scrollbar-thumb, | |||
| .settings-body::-webkit-scrollbar-thumb { | |||
| background: #b8c9e6; | |||
| border-radius: 999px; | |||
| border: 2px solid #eaf0fb; | |||
| } | |||
| .kanban-wrapper::-webkit-scrollbar-thumb:hover, | |||
| .settings-body::-webkit-scrollbar-thumb:hover { | |||
| background: #97afd8; | |||
| } | |||
| /* Small screens */ | |||
| @media (max-width: 900px) { | |||
| .kanban-wrapper { | |||
| padding: 0.7rem; | |||
| height: calc(100vh - 62px); | |||
| } | |||
| .kanban-corner, | |||
| .kanban-lane-header { | |||
| min-width: 190px; | |||
| } | |||
| .kanban-col-header, | |||
| .kanban-cell { | |||
| min-width: 206px; | |||
| } | |||
| .kanban-board-title { | |||
| font-size: clamp(0.92rem, 2.8vw, 1.1rem); | |||
| } | |||
| .board-header-actions { | |||
| gap: 0.35rem !important; | |||
| } | |||
| .board-header-actions .btn { | |||
| padding-left: 0.48rem; | |||
| padding-right: 0.48rem; | |||
| } | |||
| } | |||
| @media (max-width: 640px) { | |||
| .kanban-settings-panel { | |||
| width: 100vw; | |||
| border-left: 0; | |||
| box-shadow: none; | |||
| } | |||
| .settings-body { | |||
| padding-bottom: 1.25rem; | |||
| } | |||
| } | |||
| @@ -0,0 +1,281 @@ | |||
| :root { | |||
| --bg: #f4f7fb; | |||
| --bg-accent: #ebf2ff; | |||
| --surface: #ffffff; | |||
| --surface-alt: #f7faff; | |||
| --ink: #1f2937; | |||
| --muted: #5f6d85; | |||
| --line: #d9e3f5; | |||
| --brand: #1363df; | |||
| --brand-strong: #0b4fb5; | |||
| --brand-soft: #eaf2ff; | |||
| --danger: #b42318; | |||
| --success: #15803d; | |||
| --radius-sm: 10px; | |||
| --radius-md: 14px; | |||
| --radius-lg: 18px; | |||
| --shadow-sm: 0 6px 18px rgba(20, 40, 80, 0.08); | |||
| --shadow-md: 0 18px 40px rgba(20, 40, 80, 0.12); | |||
| } | |||
| *, | |||
| *::before, | |||
| *::after { | |||
| box-sizing: border-box; | |||
| } | |||
| html, | |||
| body { | |||
| min-height: 100%; | |||
| } | |||
| body { | |||
| margin: 0; | |||
| color: var(--ink); | |||
| font-family: "Manrope", "Segoe UI", sans-serif; | |||
| background: | |||
| radial-gradient(90rem 45rem at -20% -15%, #dbe9ff 0%, transparent 45%), | |||
| radial-gradient(80rem 40rem at 120% -10%, #d8f0ff 0%, transparent 40%), | |||
| linear-gradient(180deg, var(--bg-accent) 0%, var(--bg) 26rem, var(--bg) 100%); | |||
| line-height: 1.55; | |||
| } | |||
| h1, | |||
| h2, | |||
| h3, | |||
| h4, | |||
| h5, | |||
| h6, | |||
| .h1, | |||
| .h2, | |||
| .h3, | |||
| .h4, | |||
| .h5, | |||
| .h6 { | |||
| color: #182032; | |||
| letter-spacing: -0.02em; | |||
| } | |||
| h1, | |||
| .h1, | |||
| h2, | |||
| .h2 { | |||
| font-family: "Fraunces", Georgia, serif; | |||
| } | |||
| p, | |||
| label, | |||
| li { | |||
| color: var(--ink); | |||
| } | |||
| .text-muted { | |||
| color: var(--muted) !important; | |||
| } | |||
| a { | |||
| color: var(--brand); | |||
| text-decoration: none; | |||
| transition: color 0.16s ease, opacity 0.16s ease; | |||
| } | |||
| a:hover { | |||
| color: var(--brand-strong); | |||
| } | |||
| main.routekit-main { | |||
| padding-top: 2rem; | |||
| padding-bottom: 2.75rem; | |||
| animation: page-fade 260ms ease-out; | |||
| } | |||
| .container { | |||
| max-width: 1120px; | |||
| } | |||
| .rk-topnav { | |||
| position: sticky; | |||
| top: 0; | |||
| z-index: 1000; | |||
| backdrop-filter: blur(8px); | |||
| background: linear-gradient(120deg, #102241 0%, #173a72 56%, #1c4c90 100%); | |||
| border-bottom: 1px solid rgba(255, 255, 255, 0.14); | |||
| box-shadow: 0 8px 24px rgba(8, 20, 48, 0.26); | |||
| } | |||
| .rk-navbar-brand { | |||
| font-weight: 800; | |||
| letter-spacing: 0.03em; | |||
| color: #ffffff !important; | |||
| } | |||
| .rk-navbar-brand span { | |||
| color: #d5e6ff; | |||
| font-weight: 600; | |||
| margin-left: 0.35rem; | |||
| } | |||
| .navbar .nav-link { | |||
| border-radius: 999px; | |||
| color: rgba(238, 244, 255, 0.86) !important; | |||
| font-weight: 600; | |||
| padding: 0.35rem 0.8rem !important; | |||
| } | |||
| .navbar .nav-link:hover { | |||
| color: #ffffff !important; | |||
| background-color: rgba(255, 255, 255, 0.13); | |||
| } | |||
| .navbar .nav-link.active { | |||
| color: #fff !important; | |||
| background: rgba(255, 255, 255, 0.2); | |||
| } | |||
| .dropdown-menu { | |||
| border: 1px solid var(--line); | |||
| border-radius: var(--radius-sm); | |||
| box-shadow: var(--shadow-sm); | |||
| } | |||
| .dropdown-item { | |||
| border-radius: 8px; | |||
| } | |||
| .card { | |||
| border: 1px solid var(--line); | |||
| border-radius: var(--radius-md); | |||
| background: var(--surface); | |||
| box-shadow: var(--shadow-sm); | |||
| } | |||
| .card.border-danger { | |||
| border-color: #f5c2c7 !important; | |||
| } | |||
| .card .card-title { | |||
| color: #1a2440; | |||
| } | |||
| .btn { | |||
| border-radius: 11px; | |||
| font-weight: 700; | |||
| letter-spacing: 0.01em; | |||
| } | |||
| .btn-primary { | |||
| border-color: var(--brand); | |||
| background: linear-gradient(180deg, #2a7dff 0%, var(--brand) 100%); | |||
| } | |||
| .btn-primary:hover, | |||
| .btn-primary:focus { | |||
| border-color: var(--brand-strong); | |||
| background: linear-gradient(180deg, #1f6fe8 0%, var(--brand-strong) 100%); | |||
| } | |||
| .btn-outline-secondary { | |||
| border-color: #bcc9df; | |||
| color: #33435f; | |||
| } | |||
| .btn-outline-secondary:hover, | |||
| .btn-outline-secondary:focus { | |||
| border-color: #8fa4c9; | |||
| background: #edf3ff; | |||
| color: #243552; | |||
| } | |||
| .btn-danger { | |||
| background: #c9312a; | |||
| border-color: #b42318; | |||
| } | |||
| .form-control { | |||
| border-color: #c9d6ef; | |||
| border-radius: var(--radius-sm); | |||
| padding: 0.62rem 0.82rem; | |||
| } | |||
| .form-control:focus { | |||
| border-color: #8eb3ef; | |||
| box-shadow: 0 0 0 0.2rem rgba(19, 99, 223, 0.14); | |||
| } | |||
| code, | |||
| pre { | |||
| border-radius: 8px; | |||
| } | |||
| pre { | |||
| background: #eff4ff; | |||
| border: 1px solid #d7e3fb; | |||
| padding: 0.72rem; | |||
| } | |||
| .alert { | |||
| border-radius: var(--radius-sm); | |||
| border-width: 1px; | |||
| } | |||
| .alert-danger { | |||
| color: #5f1014; | |||
| background-color: #fdebec; | |||
| border-color: #f8cfd3; | |||
| } | |||
| .alert-success { | |||
| color: #113d21; | |||
| background-color: #e7f8ee; | |||
| border-color: #cdeedb; | |||
| } | |||
| .row.gy-3 > [class*="col-"] .card, | |||
| .row.g-3 > [class*="col-"] .card { | |||
| animation: rise-in 280ms ease-out both; | |||
| } | |||
| .row.gy-3 > [class*="col-"]:nth-child(2) .card, | |||
| .row.g-3 > [class*="col-"]:nth-child(2) .card { | |||
| animation-delay: 50ms; | |||
| } | |||
| .row.gy-3 > [class*="col-"]:nth-child(3) .card, | |||
| .row.g-3 > [class*="col-"]:nth-child(3) .card { | |||
| animation-delay: 100ms; | |||
| } | |||
| @keyframes page-fade { | |||
| from { | |||
| opacity: 0; | |||
| transform: translateY(8px); | |||
| } | |||
| to { | |||
| opacity: 1; | |||
| transform: translateY(0); | |||
| } | |||
| } | |||
| @keyframes rise-in { | |||
| from { | |||
| opacity: 0; | |||
| transform: translateY(12px); | |||
| } | |||
| to { | |||
| opacity: 1; | |||
| transform: translateY(0); | |||
| } | |||
| } | |||
| @media (max-width: 991.98px) { | |||
| .navbar .navbar-collapse { | |||
| margin-top: 0.65rem; | |||
| border-top: 1px solid rgba(255, 255, 255, 0.18); | |||
| padding-top: 0.6rem; | |||
| } | |||
| .navbar .nav-link { | |||
| display: inline-flex; | |||
| margin-bottom: 0.35rem; | |||
| } | |||
| } | |||
| @@ -0,0 +1,298 @@ | |||
| /* kanban-board.js - grid rendering and drag-drop between cells */ | |||
| (function () { | |||
| 'use strict'; | |||
| var boardId = KANBAN.boardId; | |||
| var dragState = { | |||
| active: false, | |||
| x: 0, | |||
| y: 0, | |||
| rafId: 0 | |||
| }; | |||
| function post(url, data, cb) { | |||
| var params = new URLSearchParams(); | |||
| Object.keys(data).forEach(function (k) { params.append(k, data[k]); }); | |||
| fetch(url, { | |||
| method: 'POST', | |||
| headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |||
| body: params.toString() | |||
| }) | |||
| .then(function (r) { return r.json(); }) | |||
| .then(cb) | |||
| .catch(function (e) { console.error(url, e); }); | |||
| } | |||
| function esc(s) { | |||
| return String(s) | |||
| .replace(/&/g, '&') | |||
| .replace(/</g, '<') | |||
| .replace(/>/g, '>') | |||
| .replace(/"/g, '"'); | |||
| } | |||
| function applyGridTemplate() { | |||
| var grid = document.getElementById('kanban-grid'); | |||
| var colHs = grid.querySelectorAll('.kanban-col-header'); | |||
| var cols = '240px'; | |||
| colHs.forEach(function () { cols += ' 220px'; }); | |||
| grid.style.gridTemplateColumns = cols; | |||
| } | |||
| function buildCardEl(card) { | |||
| var div = document.createElement('div'); | |||
| div.className = 'kanban-card'; | |||
| div.dataset.id = card.id; | |||
| div.dataset.columnId = card.column_id; | |||
| div.dataset.laneId = card.swim_lane_id; | |||
| div.innerHTML = | |||
| '<div class="card-job-number">' + esc(card.job_number || '') + '</div>' + | |||
| '<div class="card-job-name">' + esc(card.job_name || '') + '</div>'; | |||
| div.addEventListener('click', function () { | |||
| window.KanbanModal.openEdit( | |||
| card.id, | |||
| card.column_id, | |||
| card.swim_lane_id, | |||
| card.job_number, | |||
| card.job_name | |||
| ); | |||
| }); | |||
| return div; | |||
| } | |||
| function renderCards() { | |||
| KANBAN.cards.forEach(function (card) { | |||
| var cell = document.querySelector( | |||
| '.kanban-cell[data-col-id="' + card.column_id + '"][data-lane-id="' + card.swim_lane_id + '"]' | |||
| ); | |||
| if (cell) { | |||
| cell.appendChild(buildCardEl(card)); | |||
| } | |||
| }); | |||
| } | |||
| function handleDragEnd(evt) { | |||
| var cardId = evt.item.dataset.id; | |||
| var newColId = evt.to.dataset.colId; | |||
| var newLaneId = evt.to.dataset.laneId; | |||
| var newPos = evt.newIndex; | |||
| var siblings = []; | |||
| evt.to.querySelectorAll('.kanban-card').forEach(function (el) { | |||
| siblings.push(el.dataset.id); | |||
| }); | |||
| var card = KANBAN.cards.find(function (c) { return String(c.id) === String(cardId); }); | |||
| if (card) { | |||
| card.column_id = parseInt(newColId, 10); | |||
| card.swim_lane_id = parseInt(newLaneId, 10); | |||
| card.position = newPos; | |||
| } | |||
| evt.item.dataset.columnId = newColId; | |||
| evt.item.dataset.laneId = newLaneId; | |||
| post('/cards/' + cardId + '/move', { | |||
| column_id: newColId, | |||
| swim_lane_id: newLaneId, | |||
| position: newPos, | |||
| sibling_ids: siblings.join(',') | |||
| }, function (res) { | |||
| if (!res.ok) console.error('Move failed', res); | |||
| }); | |||
| } | |||
| function createCellSortable(cell) { | |||
| Sortable.create(cell, { | |||
| group: 'cards', | |||
| animation: 150, | |||
| ghostClass: 'sortable-ghost', | |||
| chosenClass: 'sortable-chosen', | |||
| handle: '.kanban-card', | |||
| delayOnTouchOnly: true, | |||
| delay: 120, | |||
| touchStartThreshold: 3, | |||
| fallbackTolerance: 4, | |||
| scroll: true, | |||
| bubbleScroll: true, | |||
| scrollSensitivity: 140, | |||
| scrollSpeed: 32, | |||
| onStart: function () { startEdgeAutoScroll(); }, | |||
| onEnd: function (evt) { | |||
| stopEdgeAutoScroll(); | |||
| handleDragEnd(evt); | |||
| } | |||
| }); | |||
| } | |||
| function updatePointerFromEvent(evt) { | |||
| if (!evt) return; | |||
| if (evt.touches && evt.touches.length > 0) { | |||
| dragState.x = evt.touches[0].clientX; | |||
| dragState.y = evt.touches[0].clientY; | |||
| return; | |||
| } | |||
| if (evt.clientX !== undefined && evt.clientY !== undefined) { | |||
| dragState.x = evt.clientX; | |||
| dragState.y = evt.clientY; | |||
| } | |||
| } | |||
| function edgeScrollStep() { | |||
| if (!dragState.active) return; | |||
| var wrapper = document.querySelector('.kanban-wrapper'); | |||
| if (wrapper) { | |||
| var rect = wrapper.getBoundingClientRect(); | |||
| var edge = 110; | |||
| var maxStep = 40; | |||
| var dx = 0; | |||
| var dy = 0; | |||
| if (dragState.x > 0 && dragState.x < rect.left + edge) { | |||
| dx = -Math.min(maxStep, Math.ceil((rect.left + edge - dragState.x) / 3)); | |||
| } else if (dragState.x > rect.right - edge && dragState.x < rect.right + edge) { | |||
| dx = Math.min(maxStep, Math.ceil((dragState.x - (rect.right - edge)) / 3)); | |||
| } | |||
| if (dragState.y > 0 && dragState.y < rect.top + edge) { | |||
| dy = -Math.min(maxStep, Math.ceil((rect.top + edge - dragState.y) / 3)); | |||
| } else if (dragState.y > rect.bottom - edge && dragState.y < rect.bottom + edge) { | |||
| dy = Math.min(maxStep, Math.ceil((dragState.y - (rect.bottom - edge)) / 3)); | |||
| } | |||
| if (dx !== 0) wrapper.scrollLeft += dx; | |||
| if (dy !== 0) wrapper.scrollTop += dy; | |||
| } | |||
| dragState.rafId = window.requestAnimationFrame(edgeScrollStep); | |||
| } | |||
| function startEdgeAutoScroll() { | |||
| if (dragState.active) return; | |||
| dragState.active = true; | |||
| document.addEventListener('pointermove', updatePointerFromEvent, { passive: true }); | |||
| document.addEventListener('touchmove', updatePointerFromEvent, { passive: true }); | |||
| document.addEventListener('dragover', updatePointerFromEvent, { passive: true }); | |||
| dragState.rafId = window.requestAnimationFrame(edgeScrollStep); | |||
| } | |||
| function stopEdgeAutoScroll() { | |||
| if (!dragState.active) return; | |||
| dragState.active = false; | |||
| if (dragState.rafId) { | |||
| window.cancelAnimationFrame(dragState.rafId); | |||
| dragState.rafId = 0; | |||
| } | |||
| document.removeEventListener('pointermove', updatePointerFromEvent); | |||
| document.removeEventListener('touchmove', updatePointerFromEvent); | |||
| document.removeEventListener('dragover', updatePointerFromEvent); | |||
| } | |||
| function initSortables() { | |||
| document.querySelectorAll('.kanban-cell').forEach(createCellSortable); | |||
| } | |||
| document.getElementById('btn-add-card').addEventListener('click', function () { | |||
| window.KanbanModal.openCreate(boardId, null, null); | |||
| }); | |||
| window.KanbanBoard = { | |||
| onCardCreated: function (card) { | |||
| KANBAN.cards.push(card); | |||
| var cell = document.querySelector( | |||
| '.kanban-cell[data-col-id="' + card.column_id + '"][data-lane-id="' + card.swim_lane_id + '"]' | |||
| ); | |||
| if (cell) { | |||
| cell.appendChild(buildCardEl(card)); | |||
| } | |||
| }, | |||
| onCardUpdated: function (id, jobNumber, jobName) { | |||
| var card = KANBAN.cards.find(function (c) { return String(c.id) === String(id); }); | |||
| if (card) { | |||
| card.job_number = jobNumber; | |||
| card.job_name = jobName; | |||
| } | |||
| var el = document.querySelector('.kanban-card[data-id="' + id + '"]'); | |||
| if (el) { | |||
| el.querySelector('.card-job-number').textContent = jobNumber; | |||
| el.querySelector('.card-job-name').textContent = jobName; | |||
| } | |||
| }, | |||
| onCardDeleted: function (id) { | |||
| KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.id) !== String(id); }); | |||
| var el = document.querySelector('.kanban-card[data-id="' + id + '"]'); | |||
| if (el) el.remove(); | |||
| }, | |||
| addColumn: function (col) { | |||
| var grid = document.getElementById('kanban-grid'); | |||
| var headers = grid.querySelectorAll('.kanban-col-header'); | |||
| var refNode = headers.length ? headers[headers.length - 1].nextSibling : null; | |||
| var hdr = document.createElement('div'); | |||
| hdr.className = 'kanban-col-header'; | |||
| hdr.dataset.colId = col.id; | |||
| hdr.innerHTML = '<span class="col-label">' + esc(col.name) + '</span>'; | |||
| grid.insertBefore(hdr, refNode); | |||
| var laneHeaders = grid.querySelectorAll('.kanban-lane-header'); | |||
| laneHeaders.forEach(function (lh) { | |||
| var laneId = lh.dataset.laneId; | |||
| var cell = document.createElement('div'); | |||
| cell.className = 'kanban-cell'; | |||
| cell.dataset.colId = col.id; | |||
| cell.dataset.laneId = laneId; | |||
| var row = lh.parentNode; | |||
| row.appendChild(cell); | |||
| createCellSortable(cell); | |||
| }); | |||
| applyGridTemplate(); | |||
| }, | |||
| removeColumn: function (colId) { | |||
| document.querySelector('.kanban-col-header[data-col-id="' + colId + '"]').remove(); | |||
| document.querySelectorAll('.kanban-cell[data-col-id="' + colId + '"]').forEach(function (el) { el.remove(); }); | |||
| KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.column_id) !== String(colId); }); | |||
| applyGridTemplate(); | |||
| }, | |||
| addLane: function (lane) { | |||
| var grid = document.getElementById('kanban-grid'); | |||
| var colHeaders = grid.querySelectorAll('.kanban-col-header'); | |||
| var lh = document.createElement('div'); | |||
| lh.className = 'kanban-lane-header'; | |||
| lh.dataset.laneId = lane.id; | |||
| lh.innerHTML = '<span class="lane-label">' + esc(lane.name) + '</span>'; | |||
| grid.appendChild(lh); | |||
| colHeaders.forEach(function (ch) { | |||
| var cell = document.createElement('div'); | |||
| cell.className = 'kanban-cell'; | |||
| cell.dataset.colId = ch.dataset.colId; | |||
| cell.dataset.laneId = lane.id; | |||
| grid.appendChild(cell); | |||
| createCellSortable(cell); | |||
| }); | |||
| applyGridTemplate(); | |||
| }, | |||
| removeLane: function (laneId) { | |||
| document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"]').remove(); | |||
| document.querySelectorAll('.kanban-cell[data-lane-id="' + laneId + '"]').forEach(function (el) { el.remove(); }); | |||
| KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.swim_lane_id) !== String(laneId); }); | |||
| }, | |||
| renameColumn: function (colId, name) { | |||
| var hdr = document.querySelector('.kanban-col-header[data-col-id="' + colId + '"] .col-label'); | |||
| if (hdr) hdr.textContent = name; | |||
| }, | |||
| renameLane: function (laneId, name) { | |||
| var hdr = document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"] .lane-label'); | |||
| if (hdr) hdr.textContent = name; | |||
| } | |||
| }; | |||
| applyGridTemplate(); | |||
| renderCards(); | |||
| initSortables(); | |||
| })(); | |||
| @@ -0,0 +1,174 @@ | |||
| /* kanban-modal.js — card create/edit modal */ | |||
| (function () { | |||
| 'use strict'; | |||
| var modal = document.getElementById('cardModal'); | |||
| var bsModal = new bootstrap.Modal(modal); | |||
| var titleEl = document.getElementById('cardModalLabel'); | |||
| var cardIdEl = document.getElementById('card-id'); | |||
| var colIdEl = document.getElementById('card-column-id'); | |||
| var laneIdEl = document.getElementById('card-lane-id'); | |||
| var jobNumEl = document.getElementById('card-job-number'); | |||
| var jobNameEl = document.getElementById('card-job-name'); | |||
| var errEl = document.getElementById('card-modal-error'); | |||
| var btnSave = document.getElementById('btn-save-card'); | |||
| var btnDelete = document.getElementById('btn-delete-card'); | |||
| var boardId = KANBAN.boardId; | |||
| /* ── Helpers ─────────────────────────────────────────────── */ | |||
| function post(url, data, cb) { | |||
| var params = new URLSearchParams(); | |||
| Object.keys(data).forEach(function (k) { params.append(k, data[k]); }); | |||
| fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() }) | |||
| .then(function (r) { return r.json(); }) | |||
| .then(cb) | |||
| .catch(function (e) { showError('Network error: ' + e); }); | |||
| } | |||
| function showError(msg) { | |||
| errEl.textContent = msg; | |||
| errEl.classList.remove('d-none'); | |||
| } | |||
| function clearError() { | |||
| errEl.textContent = ''; | |||
| errEl.classList.add('d-none'); | |||
| } | |||
| /* ── Open for create ──────────────────────────────────────── */ | |||
| function openCreate(bId, colId, laneId) { | |||
| titleEl.textContent = 'Add Card'; | |||
| cardIdEl.value = ''; | |||
| colIdEl.value = colId || ''; | |||
| laneIdEl.value = laneId || ''; | |||
| jobNumEl.value = ''; | |||
| jobNameEl.value = ''; | |||
| btnDelete.classList.add('d-none'); | |||
| clearError(); | |||
| bsModal.show(); | |||
| jobNumEl.focus(); | |||
| } | |||
| /* ── Open for edit ───────────────────────────────────────── */ | |||
| function openEdit(id, colId, laneId, jobNum, jobName) { | |||
| titleEl.textContent = 'Edit Card'; | |||
| cardIdEl.value = id; | |||
| colIdEl.value = colId; | |||
| laneIdEl.value = laneId; | |||
| jobNumEl.value = jobNum || ''; | |||
| jobNameEl.value = jobName || ''; | |||
| btnDelete.classList.remove('d-none'); | |||
| clearError(); | |||
| bsModal.show(); | |||
| jobNumEl.focus(); | |||
| } | |||
| /* ── Save ─────────────────────────────────────────────────── */ | |||
| btnSave.addEventListener('click', function () { | |||
| clearError(); | |||
| var id = cardIdEl.value; | |||
| var colId = colIdEl.value; | |||
| var laneId = laneIdEl.value; | |||
| var jNum = jobNumEl.value.trim(); | |||
| var jName = jobNameEl.value.trim(); | |||
| if (!jNum && !jName) { | |||
| showError('Enter at least a job number or job name.'); | |||
| return; | |||
| } | |||
| if (id) { | |||
| // Update existing | |||
| post('/cards/' + id, { job_number: jNum, job_name: jName }, function (res) { | |||
| if (res.ok) { | |||
| bsModal.hide(); | |||
| window.KanbanBoard.onCardUpdated(id, res.job_number, res.job_name); | |||
| } else { | |||
| showError(res.error || 'Save failed.'); | |||
| } | |||
| }); | |||
| } else { | |||
| // Create new — if no col/lane selected show column/lane picker | |||
| if (!colId || !laneId) { | |||
| showError('Please choose a column and swim lane first.'); | |||
| return; | |||
| } | |||
| post('/cards', { | |||
| board_id: boardId, | |||
| column_id: colId, | |||
| swim_lane_id: laneId, | |||
| job_number: jNum, | |||
| job_name: jName | |||
| }, function (res) { | |||
| if (res.ok) { | |||
| bsModal.hide(); | |||
| window.KanbanBoard.onCardCreated(res); | |||
| } else { | |||
| showError(res.error || 'Save failed.'); | |||
| } | |||
| }); | |||
| } | |||
| }); | |||
| /* ── Delete ──────────────────────────────────────────────── */ | |||
| btnDelete.addEventListener('click', function () { | |||
| if (!confirm('Delete this card?')) return; | |||
| var id = cardIdEl.value; | |||
| post('/cards/' + id + '/delete', {}, function (res) { | |||
| if (res.ok) { | |||
| bsModal.hide(); | |||
| window.KanbanBoard.onCardDeleted(id); | |||
| } else { | |||
| showError(res.error || 'Delete failed.'); | |||
| } | |||
| }); | |||
| }); | |||
| /* ── Column/Lane picker when Add Card clicked with no cell ── */ | |||
| // Populated lazily from board data | |||
| modal.addEventListener('shown.bs.modal', function () { | |||
| if (!cardIdEl.value && (!colIdEl.value || !laneIdEl.value)) { | |||
| injectPicker(); | |||
| } | |||
| }); | |||
| function injectPicker() { | |||
| if (document.getElementById('card-picker')) return; | |||
| var picker = document.createElement('div'); | |||
| picker.id = 'card-picker'; | |||
| picker.className = 'row g-2 mb-3'; | |||
| var colSel = '<select class="form-select form-select-sm" id="pick-col"><option value="">-- Column --</option>'; | |||
| var laneSel = '<select class="form-select form-select-sm" id="pick-lane"><option value="">-- Swim Lane --</option>'; | |||
| document.querySelectorAll('.kanban-col-header').forEach(function (el) { | |||
| colSel += '<option value="' + el.dataset.colId + '">' + el.querySelector('.col-label').textContent + '</option>'; | |||
| }); | |||
| document.querySelectorAll('.kanban-lane-header').forEach(function (el) { | |||
| laneSel += '<option value="' + el.dataset.laneId + '">' + el.querySelector('.lane-label').textContent + '</option>'; | |||
| }); | |||
| colSel += '</select>'; | |||
| laneSel += '</select>'; | |||
| picker.innerHTML = | |||
| '<div class="col"><label class="form-label small">Column</label>' + colSel + '</div>' + | |||
| '<div class="col"><label class="form-label small">Swim Lane</label>' + laneSel + '</div>'; | |||
| var first = document.getElementById('card-job-number').closest('.mb-3'); | |||
| modal.querySelector('.modal-body').insertBefore(picker, first); | |||
| document.getElementById('pick-col').addEventListener('change', function () { | |||
| colIdEl.value = this.value; | |||
| }); | |||
| document.getElementById('pick-lane').addEventListener('change', function () { | |||
| laneIdEl.value = this.value; | |||
| }); | |||
| } | |||
| /* ── Public API ──────────────────────────────────────────── */ | |||
| window.KanbanModal = { openCreate: openCreate, openEdit: openEdit }; | |||
| })(); | |||
| @@ -0,0 +1,235 @@ | |||
| /* kanban-settings.js — settings panel: add/rename/delete/reorder columns and lanes */ | |||
| (function () { | |||
| 'use strict'; | |||
| var boardId = KANBAN.boardId; | |||
| var panel = document.getElementById('settings-panel'); | |||
| var overlay = document.getElementById('settings-overlay'); | |||
| /* ── Panel open/close ────────────────────────────────────── */ | |||
| document.getElementById('btn-settings').addEventListener('click', openPanel); | |||
| document.getElementById('btn-close-settings').addEventListener('click', closePanel); | |||
| overlay.addEventListener('click', closePanel); | |||
| function openPanel() { | |||
| panel.classList.add('open'); | |||
| overlay.classList.remove('d-none'); | |||
| } | |||
| function closePanel() { | |||
| panel.classList.remove('open'); | |||
| overlay.classList.add('d-none'); | |||
| } | |||
| /* ── Helpers ─────────────────────────────────────────────── */ | |||
| function post(url, data, cb) { | |||
| var params = new URLSearchParams(); | |||
| Object.keys(data).forEach(function (k) { params.append(k, data[k]); }); | |||
| fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() }) | |||
| .then(function (r) { return r.json(); }) | |||
| .then(cb) | |||
| .catch(function (e) { console.error(url, e); }); | |||
| } | |||
| function postJson(url, payload, cb) { | |||
| fetch(url, { | |||
| method: 'POST', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify(payload) | |||
| }).then(function (r) { return r.json(); }).then(cb) | |||
| .catch(function (e) { console.error(url, e); }); | |||
| } | |||
| function collectOrder(listId) { | |||
| return Array.from(document.querySelectorAll('#' + listId + ' li')).map(function (li, idx) { | |||
| return { id: parseInt(li.dataset.id), position: idx }; | |||
| }); | |||
| } | |||
| function buildListItem(id, name, editClass, deleteClass, labelClass) { | |||
| var li = document.createElement('li'); | |||
| li.className = 'list-group-item d-flex align-items-center gap-2 py-2'; | |||
| li.dataset.id = id; | |||
| li.innerHTML = | |||
| '<i class="bi bi-grip-vertical text-muted drag-handle" style="cursor:grab;"></i>' + | |||
| '<span class="flex-grow-1 ' + labelClass + '">' + esc(name) + '</span>' + | |||
| '<button class="btn btn-sm btn-link p-0 text-secondary ' + editClass + '" title="Rename"><i class="bi bi-pencil"></i></button>' + | |||
| '<button class="btn btn-sm btn-link p-0 text-danger ' + deleteClass + '" title="Delete"><i class="bi bi-trash"></i></button>'; | |||
| return li; | |||
| } | |||
| function esc(s) { | |||
| return String(s) | |||
| .replace(/&/g, '&').replace(/</g, '<') | |||
| .replace(/>/g, '>').replace(/"/g, '"'); | |||
| } | |||
| /* ── Sortable reorder ─────────────────────────────────────── */ | |||
| function initSortable(listId, reorderUrl) { | |||
| var el = document.getElementById(listId); | |||
| Sortable.create(el, { | |||
| handle: '.drag-handle', | |||
| animation: 150, | |||
| onEnd: function () { | |||
| postJson(reorderUrl, collectOrder(listId), function (res) { | |||
| if (!res.ok) console.error('Reorder failed', res); | |||
| }); | |||
| } | |||
| }); | |||
| } | |||
| initSortable('col-list', '/columns/reorder'); | |||
| initSortable('lane-list', '/swimlanes/reorder'); | |||
| /* ═══════════════════════════════════════════════════════════ | |||
| COLUMNS | |||
| ═══════════════════════════════════════════════════════════ */ | |||
| /* ── Add column ───────────────────────────────────────────── */ | |||
| document.getElementById('btn-add-column').addEventListener('click', function () { | |||
| document.getElementById('col-add-form').classList.remove('d-none'); | |||
| document.getElementById('col-add-input').focus(); | |||
| }); | |||
| document.getElementById('btn-col-add-cancel').addEventListener('click', function () { | |||
| document.getElementById('col-add-form').classList.add('d-none'); | |||
| document.getElementById('col-add-input').value = ''; | |||
| }); | |||
| document.getElementById('btn-col-add-save').addEventListener('click', function () { | |||
| var name = document.getElementById('col-add-input').value.trim(); | |||
| if (!name) return; | |||
| post('/columns', { board_id: boardId, name: name }, function (res) { | |||
| if (!res.ok) { alert(res.error || 'Failed'); return; } | |||
| document.getElementById('col-add-form').classList.add('d-none'); | |||
| document.getElementById('col-add-input').value = ''; | |||
| var li = buildListItem(res.id, res.name, 'btn-edit-col', 'btn-delete-col', 'col-label-text'); | |||
| document.getElementById('col-list').appendChild(li); | |||
| bindColItem(li); | |||
| window.KanbanBoard.addColumn(res); | |||
| }); | |||
| }); | |||
| /* ── Bind edit/delete on existing column items ─────────────── */ | |||
| function bindColItem(li) { | |||
| li.querySelector('.btn-edit-col').addEventListener('click', function () { | |||
| startRename(li, '.col-label-text', function (newName, done) { | |||
| post('/columns/' + li.dataset.id, { name: newName }, function (res) { | |||
| if (res.ok) { | |||
| done(true); | |||
| window.KanbanBoard.renameColumn(li.dataset.id, newName); | |||
| } else { | |||
| done(false); | |||
| alert(res.error || 'Rename failed'); | |||
| } | |||
| }); | |||
| }); | |||
| }); | |||
| li.querySelector('.btn-delete-col').addEventListener('click', function () { | |||
| if (!confirm('Delete this column and all its cards?')) return; | |||
| post('/columns/' + li.dataset.id + '/delete', {}, function (res) { | |||
| if (res.ok) { | |||
| window.KanbanBoard.removeColumn(li.dataset.id); | |||
| li.remove(); | |||
| } else { | |||
| alert(res.error || 'Delete failed'); | |||
| } | |||
| }); | |||
| }); | |||
| } | |||
| document.querySelectorAll('#col-list li').forEach(bindColItem); | |||
| /* ═══════════════════════════════════════════════════════════ | |||
| SWIM LANES | |||
| ═══════════════════════════════════════════════════════════ */ | |||
| /* ── Add lane ─────────────────────────────────────────────── */ | |||
| document.getElementById('btn-add-lane').addEventListener('click', function () { | |||
| document.getElementById('lane-add-form').classList.remove('d-none'); | |||
| document.getElementById('lane-add-input').focus(); | |||
| }); | |||
| document.getElementById('btn-lane-add-cancel').addEventListener('click', function () { | |||
| document.getElementById('lane-add-form').classList.add('d-none'); | |||
| document.getElementById('lane-add-input').value = ''; | |||
| }); | |||
| document.getElementById('btn-lane-add-save').addEventListener('click', function () { | |||
| var name = document.getElementById('lane-add-input').value.trim(); | |||
| if (!name) return; | |||
| post('/swimlanes', { board_id: boardId, name: name }, function (res) { | |||
| if (!res.ok) { alert(res.error || 'Failed'); return; } | |||
| document.getElementById('lane-add-form').classList.add('d-none'); | |||
| document.getElementById('lane-add-input').value = ''; | |||
| var li = buildListItem(res.id, res.name, 'btn-edit-lane', 'btn-delete-lane', 'lane-label-text'); | |||
| document.getElementById('lane-list').appendChild(li); | |||
| bindLaneItem(li); | |||
| window.KanbanBoard.addLane(res); | |||
| }); | |||
| }); | |||
| /* ── Bind edit/delete on existing lane items ──────────────── */ | |||
| function bindLaneItem(li) { | |||
| li.querySelector('.btn-edit-lane').addEventListener('click', function () { | |||
| startRename(li, '.lane-label-text', function (newName, done) { | |||
| post('/swimlanes/' + li.dataset.id, { name: newName }, function (res) { | |||
| if (res.ok) { | |||
| done(true); | |||
| window.KanbanBoard.renameLane(li.dataset.id, newName); | |||
| } else { | |||
| done(false); | |||
| alert(res.error || 'Rename failed'); | |||
| } | |||
| }); | |||
| }); | |||
| }); | |||
| li.querySelector('.btn-delete-lane').addEventListener('click', function () { | |||
| if (!confirm('Delete this swim lane and all its cards?')) return; | |||
| post('/swimlanes/' + li.dataset.id + '/delete', {}, function (res) { | |||
| if (res.ok) { | |||
| window.KanbanBoard.removeLane(li.dataset.id); | |||
| li.remove(); | |||
| } else { | |||
| alert(res.error || 'Delete failed'); | |||
| } | |||
| }); | |||
| }); | |||
| } | |||
| document.querySelectorAll('#lane-list li').forEach(bindLaneItem); | |||
| /* ── Inline rename helper ─────────────────────────────────── */ | |||
| function startRename(li, labelSel, saveCb) { | |||
| var span = li.querySelector(labelSel); | |||
| var oldName = span.textContent.trim(); | |||
| var input = document.createElement('input'); | |||
| input.type = 'text'; | |||
| input.className = 'form-control form-control-sm inline-rename flex-grow-1'; | |||
| input.value = oldName; | |||
| span.replaceWith(input); | |||
| input.focus(); | |||
| input.select(); | |||
| function commit() { | |||
| var newName = input.value.trim(); | |||
| if (!newName || newName === oldName) { | |||
| abort(); | |||
| return; | |||
| } | |||
| saveCb(newName, function (ok) { | |||
| var replacement = document.createElement('span'); | |||
| replacement.className = labelSel.replace('.', '') + ' flex-grow-1'; | |||
| replacement.textContent = ok ? newName : oldName; | |||
| input.replaceWith(replacement); | |||
| }); | |||
| } | |||
| function abort() { | |||
| var replacement = document.createElement('span'); | |||
| replacement.className = labelSel.replace('.', '') + ' flex-grow-1'; | |||
| replacement.textContent = oldName; | |||
| input.replaceWith(replacement); | |||
| } | |||
| input.addEventListener('blur', commit); | |||
| input.addEventListener('keydown', function (e) { | |||
| if (e.key === 'Enter') { e.preventDefault(); commit(); } | |||
| if (e.key === 'Escape') { e.preventDefault(); abort(); } | |||
| }); | |||
| } | |||
| })(); | |||
| @@ -4,4 +4,11 @@ set "ASPC_STARTER_ROOT=%~dp0" | |||
| powershell -NoProfile -ExecutionPolicy Bypass -Command "$root = Resolve-Path '%~dp0'; $envPath = Join-Path $root '.env'; $webConfigPath = Join-Path $root 'public\web.config'; if (Test-Path $envPath) { $line = Get-Content $envPath | Where-Object { $_ -match '^\s*KeycloakClientSecret\s*=' } | Select-Object -First 1; if ($line) { $secret = ($line -split '=', 2)[1].Trim(); if ($secret.Length -ge 2 -and (($secret[0] -eq [char]34 -and $secret[-1] -eq [char]34) -or ($secret[0] -eq [char]39 -and $secret[-1] -eq [char]39))) { $secret = $secret.Substring(1, $secret.Length - 2) }; [xml]$xml = Get-Content $webConfigPath; $node = $xml.configuration.appSettings.add | Where-Object { $_.key -eq 'KeycloakClientSecret' } | Select-Object -First 1; if ($node) { $node.value = $secret } else { $newNode = $xml.CreateElement('add'); $newNode.SetAttribute('key', 'KeycloakClientSecret'); $newNode.SetAttribute('value', $secret); $xml.configuration.appSettings.AppendChild($newNode) | Out-Null }; $xml.Save($webConfigPath); Write-Host 'Injected KeycloakClientSecret from .env into public\web.config.' } else { Write-Host 'KeycloakClientSecret not found in .env. Using existing web.config value.' } } else { Write-Host '.env not found. Using existing web.config value.' }" | |||
| "C:\Program Files\IIS Express\iisexpress.exe" /config:"%~dp0applicationhost.config" | |||
| set "IISX86=%ProgramFiles(x86)%\IIS Express\iisexpress.exe" | |||
| if exist "%IISX86%" ( | |||
| "%IISX86%" /config:"%~dp0applicationhost.config" | |||
| ) else ( | |||
| echo 32-bit IIS Express not found at "%IISX86%". | |||
| echo Falling back to default IIS Express path. | |||
| "C:\Program Files\IIS Express\iisexpress.exe" /config:"%~dp0applicationhost.config" | |||
| ) | |||
| @@ -0,0 +1,40 @@ | |||
| # Skills And Lessons Learned | |||
| ## Runtime And Driver Bitness | |||
| - This project depends on a 32-bit Access driver path. | |||
| - Run IIS Express as 32-bit for local app runtime: | |||
| - `%ProgramFiles(x86)%\IIS Express\iisexpress.exe` | |||
| - `applicationhost.config` should keep `enable32BitAppOnWin64="true"` on the active app pool. | |||
| - Running 64-bit IIS Express or 64-bit script host can trigger: | |||
| - `Provider cannot be found` (3706) | |||
| - `Data source name not found and no default driver specified` | |||
| ## Migration Runner Usage | |||
| - `scripts\runMigrations.vbs` must be executed with 32-bit `cscript` on this machine: | |||
| - `C:\Windows\SysWOW64\cscript.exe //nologo scripts\runMigrations.vbs up` | |||
| - The standalone migration context differs from IIS runtime context: | |||
| - Do not assume `migration.DB.Execute` / `migration.DB.Query` always exist. | |||
| - Prefer migration helpers that can fall back to `migration.Connection` + `ADODB.Command`. | |||
| ## Flash API Contract | |||
| - `Flash_Class` supports: | |||
| - `AddError "message"` | |||
| - `Success = "message"` | |||
| - `SetError` and `SetSuccess` are not valid methods and cause `438`. | |||
| ## Linked List Node API | |||
| - `LinkedList_Node_Class` uses fields: | |||
| - `m_value` | |||
| - `m_next` | |||
| - Using `.Value` or `.Next` causes `Object doesn't support this property or method` (`438`). | |||
| ## Keycloak User Object Handling | |||
| - Treat `KeycloakCurrentUser()` defensively. | |||
| - Do not rely on `.Exists(...)` being available in every execution path. | |||
| - Safe pattern: | |||
| - Try `Item("preferred_username")` under `On Error Resume Next` | |||
| - Fallback to `Item("email")` | |||
| ## Navigation Pattern | |||
| - Top nav should include a direct `Boards` link. | |||
| - Use `Active("controller")` for active CSS state (for example `home`, `boards`). | |||
Powered by TurnKey Linux.