Pārlūkot izejas kodu

the board is working !!!

main^2
Daniel Covington pirms 1 nedēļas
vecāks
revīzija
158436081f
38 mainītis faili ar 3953 papildinājumiem un 100 dzēšanām
  1. +456
    -77
      agents.md
  2. +238
    -0
      app/controllers/BoardsController.asp
  3. +162
    -0
      app/controllers/CardsController.asp
  4. +150
    -0
      app/controllers/ColumnsController.asp
  5. +150
    -0
      app/controllers/SwimLanesController.asp
  6. +13
    -1
      app/controllers/autoload_controllers.asp
  7. +53
    -0
      app/models/POBO_board_columns.asp
  8. +48
    -0
      app/models/POBO_boards.asp
  9. +68
    -0
      app/models/POBO_cards.asp
  10. +53
    -0
      app/models/POBO_swim_lanes.asp
  11. +75
    -0
      app/repositories/board_columns_Repository.asp
  12. +90
    -0
      app/repositories/boards_Repository.asp
  13. +104
    -0
      app/repositories/cards_Repository.asp
  14. +75
    -0
      app/repositories/swim_lanes_Repository.asp
  15. +48
    -0
      app/views/Boards/Create.asp
  16. +41
    -0
      app/views/Boards/Edit.asp
  17. +38
    -0
      app/views/Boards/Index.asp
  18. +83
    -0
      app/views/Boards/SettingsPanel.asp
  19. +97
    -0
      app/views/Boards/Show.asp
  20. +32
    -0
      app/views/Cards/_modal.asp
  21. +25
    -20
      app/views/shared/header.asp
  22. +1
    -1
      applicationhost.config
  23. +4
    -0
      core/lib.ControllerRegistry.asp
  24. +24
    -0
      db/migrations/20260422100000_create_boards.asp
  25. +25
    -0
      db/migrations/20260422100001_create_board_columns.asp
  26. +25
    -0
      db/migrations/20260422100002_create_swim_lanes.asp
  27. +30
    -0
      db/migrations/20260422100003_create_cards.asp
  28. +155
    -0
      db/migrations/20260422101000_seed_sample_boards_and_layout.asp
  29. +170
    -0
      db/migrations/20260422101001_seed_sample_cards.asp
  30. Binārs
      db/webdata.accdb
  31. +27
    -0
      public/Default.asp
  32. +357
    -0
      public/css/kanban.css
  33. +281
    -0
      public/css/site.css
  34. +298
    -0
      public/js/kanban-board.js
  35. +174
    -0
      public/js/kanban-modal.js
  36. +235
    -0
      public/js/kanban-settings.js
  37. +8
    -1
      run_site.cmd
  38. +40
    -0
      skills.md

+ 456
- 77
agents.md Parādīt failu

@@ -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 = "..."`.

+ 238
- 0
app/controllers/BoardsController.asp Parādīt failu

@@ -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
%>

+ 162
- 0
app/controllers/CardsController.asp Parādīt failu

@@ -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
%>

+ 150
- 0
app/controllers/ColumnsController.asp Parādīt failu

@@ -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
%>

+ 150
- 0
app/controllers/SwimLanesController.asp Parādīt failu

@@ -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
%>

+ 13
- 1
app/controllers/autoload_controllers.asp Parādīt failu

@@ -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" -->

+ 53
- 0
app/models/POBO_board_columns.asp Parādīt failu

@@ -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
%>

+ 48
- 0
app/models/POBO_boards.asp Parādīt failu

@@ -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
%>

+ 68
- 0
app/models/POBO_cards.asp Parādīt failu

@@ -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
%>

+ 53
- 0
app/models/POBO_swim_lanes.asp Parādīt failu

@@ -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
%>

+ 75
- 0
app/repositories/board_columns_Repository.asp Parādīt failu

@@ -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
%>

+ 90
- 0
app/repositories/boards_Repository.asp Parādīt failu

@@ -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
%>

+ 104
- 0
app/repositories/cards_Repository.asp Parādīt failu

@@ -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
%>

+ 75
- 0
app/repositories/swim_lanes_Repository.asp Parādīt failu

@@ -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
%>

+ 48
- 0
app/views/Boards/Create.asp Parādīt failu

@@ -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;">&nbsp;</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>

+ 41
- 0
app/views/Boards/Edit.asp Parādīt failu

@@ -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>

+ 38
- 0
app/views/Boards/Index.asp Parādīt failu

@@ -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 %>

+ 83
- 0
app/views/Boards/SettingsPanel.asp Parādīt failu

@@ -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>

+ 97
- 0
app/views/Boards/Show.asp Parādīt failu

@@ -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) %> &mdash; 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>

+ 32
- 0
app/views/Cards/_modal.asp Parādīt failu

@@ -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>

+ 25
- 20
app/views/shared/header.asp Parādīt failu

@@ -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"
%>


+ 1
- 1
applicationhost.config Parādīt failu

@@ -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>


+ 4
- 0
core/lib.ControllerRegistry.asp Parādīt failu

@@ -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()


+ 24
- 0
db/migrations/20260422100000_create_boards.asp Parādīt failu

@@ -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
%>

+ 25
- 0
db/migrations/20260422100001_create_board_columns.asp Parādīt failu

@@ -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
%>

+ 25
- 0
db/migrations/20260422100002_create_swim_lanes.asp Parādīt failu

@@ -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
%>

+ 30
- 0
db/migrations/20260422100003_create_cards.asp Parādīt failu

@@ -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
%>

+ 155
- 0
db/migrations/20260422101000_seed_sample_boards_and_layout.asp Parādīt failu

@@ -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
%>

+ 170
- 0
db/migrations/20260422101001_seed_sample_cards.asp Parādīt failu

@@ -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
%>

Binārs
db/webdata.accdb Parādīt failu


+ 27
- 0
public/Default.asp Parādīt failu

@@ -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)


+ 357
- 0
public/css/kanban.css Parādīt failu

@@ -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;
}
}

+ 281
- 0
public/css/site.css Parādīt failu

@@ -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;
}
}

+ 298
- 0
public/js/kanban-board.js Parādīt failu

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

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();
})();

+ 174
- 0
public/js/kanban-modal.js Parādīt failu

@@ -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 };

})();

+ 235
- 0
public/js/kanban-settings.js Parādīt failu

@@ -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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

/* ── 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(); }
});
}

})();

+ 8
- 1
run_site.cmd Parādīt failu

@@ -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"
)

+ 40
- 0
skills.md Parādīt failu

@@ -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`).

Notiek ielāde…
Atcelt
Saglabāt

Powered by TurnKey Linux.