Port Lucee CRUD to MariaDB

This commit is contained in:
Repinoid
2026-10-02 11:47:34 +03:00
commit a317b3ba06
6 changed files with 614 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
# Binaries
*.exe
*.dll
*.so
*.dylib
*.class
*.jar
*.war
*.ear
*.zip
*.tar.gz
*.tar
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
*.sublime-*
# OS
.DS_Store
Thumbs.db
Desktop.ini
# Lucee / ColdFusion
WEB-INF/
cfclasses/
.cfclasses/
# Temp
tmp/
temp/
*.tmp
*.log
# Git
.git/
+205
View File
@@ -0,0 +1,205 @@
<!---
Application.cfc — конфигурация Lucee CRUD-приложения
Жизненный цикл:
1. OnApplicationStart — вызывается ОДИН раз при старте приложения
- читает TABLE_NAME из env vars (json_env в Terraform)
- создаёт таблицу CREATE TABLE IF NOT EXISTS
- сохраняет имя таблицы в application.tableName
2. OnRequestStart — вызывается ПЕРЕД каждым запросом
- копирует application.tableName → request.tableName
3. OnRequest — вызывает index.cfm с CRUD-логикой
Datasource: testds, конфигурируется из env vars с префиксом testds_
(testds_class, testds_connectionString, testds_username, testds_password, ...)
Terraform json_env должен содержать:
- TABLE_NAME — имя таблицы (по умолчанию check_point)
- testds_class — JDBC-драйвер
- testds_connectionString — строка подключения к MariaDB
- testds_username — имя пользователя БД
- testds_password — пароль пользователя БД
--->
<cfcomponent displayname="Application" output="true">
<!--- Идентификатор приложения для Lucee --->
<cfset this.Name = "maria_lucee_crud" />
<!--- Таймаут приложения: 3 часа бездействия → перезапуск (вызовет OnApplicationStart заново) --->
<cfset this.applicationTimeout = createTimeSpan(0, 0, 3, 0) />
<!--- Включаем сессии для защиты от CSRF (Lucee автоматически добавляет токены) --->
<cfset this.sessionmanagement = "Yes" />
<!--- Сессия живёт 2 часа неактивности --->
<cfset this.sessiontimeout = CreateTimeSpan(0, 0, 120, 0) />
<!--- Имя datasource по умолчанию — используется в cfquery без атрибута datasource --->
<cfset this.datasource = "testds" />
<!--- Конфигурируем datasource из env vars с префиксом testds_ --->
<cfset getDS(this.datasource, this.datasource) />
<!--- Отключаем отладку на проде, таймаут запроса 20 секунд --->
<cfsetting requesttimeout="20" showdebugoutput="false" />
<!---
OnApplicationStart — выполняется ОДИН раз при старте приложения.
Здесь: создаём таблицу если её нет, сохраняем имя в application scope.
application scope — живёт пока приложение не перезапустится (таймаут 3 часа).
Используем application а не request потому что request умирает после каждого запроса.
--->
<cffunction name="OnApplicationStart" access="public" returntype="boolean">
<!--- 1. Читаем имя таблицы из переменной окружения TABLE_NAME --->
<cfset var tableName = createObject("java", "java.lang.System").getEnv().get("TABLE_NAME") />
<!--- 2. Если переменная не задана в json_env — используем значение по умолчанию --->
<cfif NOT isDefined("tableName") OR NOT len(tableName)>
<cfset tableName = "check_point" />
</cfif>
<cfif reFind("^[A-Za-z_][A-Za-z0-9_]*$", tableName) EQ 0>
<cfthrow message="TABLE_NAME must be a simple SQL identifier" />
</cfif>
<!--- 3. Сохраняем в application scope — доступно всем запросам этого приложения --->
<cfset application.tableName = tableName />
<!--- 3a. Имя сервиса — для колонки created_by («кто добавил строку»).
Задаётся в json_env манифеста (flask / lucee / nodejs): три приложения
пишут в ОДНУ таблицу, и по этой метке видно, какое именно добавило. --->
<cfset var serviceName = createObject("java", "java.lang.System").getEnv().get("SERVICE_NAME") />
<cfif NOT isDefined("serviceName") OR NOT len(serviceName)>
<cfset serviceName = "lucee" />
</cfif>
<cfset application.serviceName = serviceName />
<!--- 3b. Версия приложения — единая для всех страниц (index.cfm выводит её в футере).
Раньше версия была захардкожена в разметке и расходилась с Flask/Node (1.0.0). --->
<cfset application.appVersion = "1.0.0" />
<!--- 4. Создаём таблицу если она ещё не существует --->
<!--- id: автоинкрементный первичный ключ --->
<!--- value: текстовое поле для CRUD-значений --->
<!--- created_at / created_by: когда и какое приложение добавило строку --->
<cftry>
<cfquery>
CREATE TABLE IF NOT EXISTS #tableName# (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`value` TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(255) NOT NULL DEFAULT ''
)
</cfquery>
<!--- Досыпаем колонки в таблицу, созданную прошлой версией: данные не теряются --->
<cfquery>
ALTER TABLE #tableName# ADD COLUMN IF NOT EXISTS created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
</cfquery>
<cfquery>
ALTER TABLE #tableName# ADD COLUMN IF NOT EXISTS created_by VARCHAR(255) NOT NULL DEFAULT ''
</cfquery>
<!--- Игнорируем ошибки: нет прав, БД недоступна --->
<cfcatch type="database"></cfcatch>
</cftry>
<cfreturn true />
</cffunction>
<!---
OnRequestStart — выполняется ПЕРЕД каждым HTTP-запросом.
Здесь: копируем имя таблицы из application в request scope.
request scope — живёт только в течение одного запроса.
Каждая страница (index.cfm) читает request.tableName.
--->
<cffunction name="OnRequestStart" access="public" returntype="boolean">
<!--- Если приложение уже стартовало — берём имя из application --->
<cfif isDefined("application.tableName")>
<cfset request.tableName = application.tableName />
<!--- Иначе (первый запрос до OnApplicationStart) — fallback --->
<cfelse>
<cfset request.tableName = "check_point" />
</cfif>
<!--- Имя сервиса — из application в request (см. OnApplicationStart) --->
<cfif isDefined("application.serviceName")>
<cfset request.serviceName = application.serviceName />
<cfelse>
<cfset request.serviceName = "lucee" />
</cfif>
<!--- Версия приложения — для футера страницы --->
<cfif isDefined("application.appVersion")>
<cfset request.appVersion = application.appVersion />
<cfelse>
<cfset request.appVersion = "1.0.0" />
</cfif>
<cfreturn true />
</cffunction>
<!---
OnRequest — обработчик каждого запроса.
Включает целевой шаблон (index.cfm) с установленной кодировкой.
--->
<cffunction name="OnRequest" access="public" returntype="void">
<cfargument name="template" type="string" required="true" />
<!--- UTF-8 для форм и URL-параметров --->
<cfset setEncoding("FORM", "UTF-8") />
<cfset setEncoding("URL", "UTF-8") />
<!--- Вычисляем web-корень для ссылок --->
<cfset local.basePath = getDirectoryFromPath(getCurrentTemplatePath()) />
<cfset request.webRoot = "../" />
<cfset request.thisPage = Replace(ReplaceNoCase(expandPath(arguments.template), local.basePath, ""), "\", "/") />
<!--- Выполняем запрошенный шаблон (index.cfm) --->
<cfinclude template="#arguments.template#" />
</cffunction>
<!---
getDS — конфигурирует datasource Lucee из переменных окружения.
Соглашение об именах env vars:
{prefix}_{field} → параметр datasource
Пример для testds:
testds_class → JDBC-драйвер
testds_connectionString → jdbc:mariadb://host:3306/db
testds_username → пользователь
testds_password → пароль
Все параметры передаются через json_env в Terraform.
--->
<cffunction name="getDS" access="private" returntype="void">
<cfargument name="dsname" type="string" required="true" hint="Имя datasource (testds)" />
<cfargument name="prefix" type="string" default="#dsname#" hint="Префикс env vars" />
<!--- Java-объект для чтения переменных окружения --->
<cfset var system = createObject("java", "java.lang.System") />
<!--- Словарь параметров datasource --->
<cfset var ds = {} />
<!--- Перебираем все возможные поля datasource --->
<cfloop list="class,connectionString,username,password,bundleName,bundleVersion,connectionLimit,liveTimeout,validate" item="field">
<!--- Читаем env var: {prefix}_{field}, например testds_username --->
<cfset var value = system.getEnv("#arguments.prefix#_#field#") />
<!--- Если переменная задана и не пуста — добавляем в datasource --->
<cfif isDefined("value") AND len(value)>
<cfset structInsert(ds, field, value) />
</cfif>
</cfloop>
<!--- Регистрируем datasource в Lucee --->
<cfset THIS.datasources[dsname] = ds />
</cffunction>
</cfcomponent>
+4
View File
@@ -0,0 +1,4 @@
# Lucee MariaDB CRUD
Lucee + MariaDB. Использует JDBC-драйвер MariaDB, заданный через `testds_*` env vars.
Деплоится через Terraform (`nubes_lucee`).
+55
View File
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="57"
height="57"
xml:space="preserve"
overflow="hidden"
version="1.1"
id="svg8"
sodipodi:docname="U_v3.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview8"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="16.226667"
inkscape:cx="27.146672"
inkscape:cy="28.317584"
inkscape:window-width="2560"
inkscape:window-height="1494"
inkscape:window-x="-11"
inkscape:window-y="-11"
inkscape:window-maximized="1"
inkscape:current-layer="svg8" /><defs
id="defs2"><clipPath
id="clip0"><rect
x="652"
y="420"
width="64"
height="75"
id="rect1" /></clipPath><clipPath
id="clip1"><rect
x="652"
y="420"
width="64"
height="70"
id="rect2" /></clipPath></defs><g
clip-path="url(#clip0)"
transform="matrix(1.0135748,0,0,1.0135748,-664.97034,-440.30567)"
id="g8"><g
clip-path="url(#clip1)"
id="g7"><g
id="g6"><path
d="m 114.028,43.1492 v 7.6592 c 0,3.7843 -3.08,6.8632 -6.866,6.8632 L 85.3367,57.5419 c -3.7853,0 -6.8655,-3.0805 -6.8655,-6.8643 v -2.5279 l 0.0555,0.009 V 15.8148 l -10.3823,2.0127 0.0045,3.8772 -0.0045,0.0015 v 28.971 c 0,9.481 7.7132,17.1923 17.1867,17.1923 l 21.8359,0.1313 c 9.474,0 17.187,-7.7117 17.187,-17.1928 v -2.6541 l 0.027,0.0045 V 15.8145 l -10.382,2.0126 0.028,25.3214 z"
fill="#001c34"
fill-rule="evenodd"
transform="matrix(1,0,0,1.01337,587.92,420.059)"
id="path6" /></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="82.3711mm" height="18.2443mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 8221.93 1821.07"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
<defs>
<style type="text/css">
<![CDATA[
.fil0 {fill:#001C34}
]]>
</style>
</defs>
<g id="Слой_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_2283355517888">
<path class="fil0" d="M477.49 479.69l-413.51 0 -63.98 276.48 178.44 0 1.17 1028.71 0 26.75 0.03 0 276.39 0 0 -871.72c0,-101.28 82.43,-183.69 183.75,-183.69l589.25 3.44c101.34,0 183.76,82.46 183.76,183.74l0 871.72 276.44 0 0 -871.72c0,-253.77 -206.46,-460.15 -460.05,-460.15l-589.52 -3.53 -162.17 0.45 0 -0.48z"/>
<path class="fil0" d="M6664.65 1813.37l1181.04 0c212.14,0 376.24,-175.01 376.24,-387.08 0,-212.13 -172.55,-384.68 -384.69,-384.68l-653.66 0c-60.22,0 -109.16,-48.94 -109.16,-109.18l0 -66.18c0,-60.19 48.94,-109.14 109.16,-109.14l695.76 0 63.74 -275.49 -759.5 0c-212.13,0 -384.66,172.53 -384.66,384.63l0 66.18c0,212.13 172.53,384.67 384.66,384.67l653.66 0c60.2,0 109.2,49 109.2,109.19 0,60.13 -49,116.1 -109.2,116.1l-1118.9 0 -53.68 270.98z"/>
<path class="fil0" d="M6200.79 483.5l-723.21 0c-215.88,0 -391.47,175.6 -391.47,391.51l0 485.5c0,248.38 202.05,450.4 450.4,450.4l989.38 0 62.13 -268.51 -1051.51 0c-96.41,0 -174.95,-85.37 -174.95,-181.88l-1.5 -39.46 923.62 0 308.51 -1.84 0 -237.95 0 -206.26c0,-215.91 -175.59,-391.51 -391.41,-391.51zm115.96 560.28l-955.19 0 0 -168.77c0,-63.96 52.07,-116.05 116.02,-116.05l723.21 0c63.91,0 115.96,52.08 115.96,116.05l0 168.77z"/>
<path class="fil0" d="M4683.49 1194.24l0 168.28c0,100.92 -81.7,178.37 -182.67,178.37l-589.87 1.23c-93.18,0 -170.28,-69.96 -181.63,-160.09l0 -458.13c11.36,-90.1 88.46,-160.07 181.63,-160.07l589.44 3.5c100.97,0 183.1,82.18 183.1,183.1l0 30.42 0 213.4zm-1230.2 -345.53l-0.89 -795.03 276.91 -53.68 0 526.07c55.74,-24.16 117.03,-37.74 181.5,-37.74l589.71 3.5c252.69,0 458.42,205.72 458.42,458.59l0 30.42 0 213.4 0 168.28c0,252.86 -205.73,458.54 -458.42,458.54l-589.71 -3.5c-252.7,0 -458.41,-205.67 -458.41,-458.56l0 -171.61 0 -240.48 0.89 -98.2z"/>
<path class="fil0" d="M3041.25 1150.84l0 204.28c0,100.93 -82.15,183.05 -183.11,183.05l-582.11 -3.46c-100.96,0 -183.11,-82.16 -183.11,-183.08l0 -67.42 1.48 0.24 0 -862.65 -276.91 53.68 0.12 103.41 -0.12 0.04 0 772.69c0,252.87 205.72,458.54 458.39,458.54l582.4 3.5c252.67,0 458.4,-205.68 458.4,-458.55l0 -70.79 0.71 0.12 0 -862.65 -276.91 53.68 0.76 675.35z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+286
View File
@@ -0,0 +1,286 @@
<!---
index.cfm — CRUD-интерфейс для таблицы в MariaDB
Логика:
1. Если POST-запрос с FORM.action — выполняем create/update/delete
и редиректим на GET (POST-redirect-GET паттерн)
2. Если GET-запрос с URL.edit — загружаем запись для редактирования
3. Всегда показываем список всех записей + форму добавления/редактирования
Безопасность:
- cfqueryparam для всех входных данных → защита от SQL-инъекций
- cflocation после POST → защита от повторной отправки формы
- cfoutput только где нужно → XSS-защита через автоматическое экранирование Lucee
Переменные:
- request.tableName — имя таблицы (из Application.cfc / json_env TABLE_NAME)
- FORM.action — create | update | delete (POST)
- FORM.value — значение записи (POST)
- FORM.id — ID записи для update/delete (POST)
- URL.edit — ID записи для редактирования (GET)
--->
<cfset tableName = request.tableName />
<!--- Имя сервиса — пишется в колонку created_by при добавлении строки --->
<cfset serviceName = request.serviceName />
<!--- ====== ОБРАБОТКА POST-ЗАПРОСОВ (create / update / delete) ====== --->
<!--- isDefined("FORM.action") — true только если страница вызвана через <form method="post"> --->
<cfif isDefined("FORM.action")>
<!--- CREATE: вставка новой записи --->
<cfif FORM.action EQ "create">
<!--- cfqueryparam cfsqltype="cf_sql_varchar" — экранирует значение, защита от SQL-инъекций --->
<cfquery name="insertRow">
INSERT INTO #tableName# (`value`, created_by)
VALUES (
<cfqueryparam value="#FORM.value#" cfsqltype="cf_sql_varchar" />,
<cfqueryparam value="#serviceName#" cfsqltype="cf_sql_varchar" />
)
</cfquery>
</cfif>
<!--- UPDATE: обновление существующей записи по ID --->
<cfif FORM.action EQ "update">
<cfquery name="updateRow">
UPDATE #tableName#
SET `value` = <cfqueryparam value="#FORM.value#" cfsqltype="cf_sql_varchar" />
WHERE id = <cfqueryparam value="#FORM.id#" cfsqltype="cf_sql_integer" />
</cfquery>
</cfif>
<!--- DELETE: удаление записи по ID --->
<cfif FORM.action EQ "delete">
<cfquery name="deleteRow">
DELETE FROM #tableName#
WHERE id = <cfqueryparam value="#FORM.id#" cfsqltype="cf_sql_integer" />
</cfquery>
</cfif>
<!--- POST-redirect-GET: после любого действия — редирект на чистый URL без POST-данных.
Это предотвращает повторную отправку формы при обновлении страницы (F5). --->
<cflocation url="index.cfm" addtoken="no" />
</cfif>
<!--- ====== РЕЖИМ РЕДАКТИРОВАНИЯ (GET-запрос с ?edit=ID) ====== --->
<!--- editRow всегда инициализируем пустым словарём.
Если URL.edit не задан или запись не найдена — editRow останется пустым. --->
<cfset editRow = {} />
<cfif isDefined("URL.edit")>
<!--- Загружаем запись по ID из URL --->
<cfquery name="editQuery">
SELECT id, `value`, created_at, created_by FROM #tableName#
WHERE id = <cfqueryparam value="#URL.edit#" cfsqltype="cf_sql_integer" />
</cfquery>
<!--- Если запись найдена — заполняем editRow данными из запроса --->
<cfif editQuery.recordCount GT 0>
<cfset editRow = { id = editQuery.id, value = editQuery.value } />
</cfif>
</cfif>
<!--- ====== СПИСОК ВСЕХ ЗАПИСЕЙ ====== --->
<cfquery name="rows">
SELECT id, `value`, created_at, created_by FROM #tableName# ORDER BY id
</cfquery>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<title>CRUD Lucee Example — <cfoutput>#tableName#</cfoutput></title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
<style>
:root {
--brand-primary: #2563eb;
--brand-primary-dark: #1d4ed8;
--brand-gray: #d1d5db;
--brand-grey-light: #f3f4f6;
--brand-destructive: #ef4444;
--brand-success: #22c55e;
--text-primary: #1a1a1a;
--text-muted: #6b7280;
--bg-card: #ffffff;
--radius: 12px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
color: var(--text-primary);
background: var(--brand-grey-light);
min-height: 100vh;
padding: 40px 16px;
}
.container { max-width: 720px; margin: 0 auto; }
/* Header */
.header {
display: flex; align-items: center; gap: 12px;
margin-bottom: 24px;
}
.header img { height: 36px; }
.header h1 { font-size: 20px; font-weight: 600; color: #001C34; }
/* Card */
.card {
background: var(--bg-card);
border-radius: var(--radius);
border: 1px solid var(--brand-gray);
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
overflow: hidden;
margin-bottom: 20px;
}
.card-header {
background: var(--brand-grey-light);
padding: 12px 16px;
font-weight: 600;
font-size: 16px;
}
.card-body { padding: 16px; }
/* Table */
table { width: 100%; border-collapse: collapse; }
th {
background: var(--brand-grey-light);
text-transform: uppercase;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
padding: 8px 12px;
text-align: left;
border-right: 1px solid var(--brand-gray);
}
th:last-child { border-right: none; text-align: center; width: 60px; }
td {
padding: 8px 12px;
border-bottom: 1px solid var(--brand-gray);
border-right: 1px solid var(--brand-gray);
}
td:last-child { border-right: none; text-align: center; width: 60px; }
tr:hover { background: rgba(243,244,246,0.5); }
tr:last-child td { border-bottom: none; }
/* Buttons */
.btn {
display: inline-flex; align-items: center; gap: 4px;
height: 32px; padding: 0 12px;
border: 1px solid var(--brand-gray);
border-radius: 6px;
background: var(--bg-card);
font-size: 14px; font-family: inherit;
cursor: pointer; white-space: nowrap;
}
.btn:hover { background: var(--brand-grey-light); }
.btn-primary {
background: var(--brand-primary); color: #fff; border-color: var(--brand-primary);
}
.btn-primary:hover { background: var(--brand-primary-dark); }
.btn-destructive { background: var(--brand-destructive); color: #fff; border-color: var(--brand-destructive); }
.btn-destructive:hover { opacity: 0.9; }
.btn-sm { height: 28px; padding: 0 8px; font-size: 13px; }
/* Form */
.form-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.form-row {
display: flex; gap: 8px; align-items: flex-end;
}
.form-row input { flex: 1; }
input[type="text"] {
height: 36px; padding: 0 12px;
border: 1px solid var(--brand-gray);
border-radius: 6px; font-size: 14px; font-family: inherit;
}
input[type="text"]:focus { outline: none; border-color: var(--brand-primary); box-shadow: 0 0 0 2px rgba(37,99,235,0.15); }
/* Action links */
/* Action column */
td.act { text-align: center; width: 60px; border-right: none; }
td.act:last-child { border-right: none; }
.empty-cell { color: var(--text-muted); padding: 24px; text-align: center; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="assets/logo.svg" alt="Nubes" />
<h1>CRUD Lucee Example</h1>
</div>
<div class="card">
<div class="card-header">Записи</div>
<div class="card-body" style="padding: 0;">
<table>
<thead>
<tr><th>ID</th><th>VALUE</th><th>ДОБАВЛЕНО</th><th>КЕМ</th><th style="text-align:center;"></th><th style="text-align:center;"></th></tr>
</thead>
<tbody>
<cfoutput query="rows">
<tr>
<td>#id#</td>
<td>#value#</td>
<td>#dateFormat(created_at, 'dd.mm.yyyy')# #timeFormat(created_at, 'HH:mm:ss')#</td>
<td>#created_by#</td>
<td style="text-align:center;">
<a href="index.cfm?edit=#id#" class="btn btn-sm">✏️</a>
</td>
<td style="text-align:center;">
<form method="post" style="display:inline">
<input type="hidden" name="action" value="delete" />
<input type="hidden" name="id" value="#id#" />
<button type="submit" class="btn btn-sm" onclick="return confirm('Удалить #id#?')">🗑️</button>
</form>
</td>
</tr>
</cfoutput>
</tbody>
</table>
<cfif rows.recordCount EQ 0>
<div class="empty-cell">Нет записей. Создайте первую.</div>
</cfif>
</div>
</div>
<div class="card">
<cfif NOT structIsEmpty(editRow)>
<cfoutput>
<div class="card-header">Редактировать #editRow.id#</div>
<div class="card-body">
<form method="post" class="form-grid">
<input type="hidden" name="action" value="update" />
<input type="hidden" name="id" value="#editRow.id#" />
<div class="form-row">
<input type="text" name="value" value="#editRow.value#" required placeholder="Значение" />
<button type="submit" class="btn btn-primary">Сохранить</button>
<a href="index.cfm" class="btn">Отмена</a>
</div>
</form>
</div>
</cfoutput>
<cfelse>
<div class="card-header">Добавить</div>
<div class="card-body">
<form method="post" class="form-grid">
<input type="hidden" name="action" value="create" />
<div class="form-row">
<input type="text" name="value" placeholder="Значение" required />
<button type="submit" class="btn btn-primary">Добавить</button>
</div>
</form>
</div>
</cfif>
</div>
</div>
<div style="text-align:center;padding:16px;color:#9ca3af;font-size:12px;">
v<cfoutput>#request.appVersion#</cfoutput> &mdash; <cfoutput>#tableName#</cfoutput>
</div>
</body>
</html>