243 lines
9.1 KiB
Twig
243 lines
9.1 KiB
Twig
{% extends 'base.html.twig' %}
|
|
{% import '_sidebar_tables.html.twig' as macros %}
|
|
|
|
{% block title %}{{ tableName }} — Browse · Jormun Admin{% endblock %}
|
|
|
|
{% block breadcrumbs %}
|
|
<a href="{{ path('dashboard') }}"><i class="bi bi-house-fill"></i> Home</a>
|
|
<span class="sep">/</span>
|
|
<span class="current">{{ tableName }}</span>
|
|
<span class="sep">/</span>
|
|
<span class="current">Browse</span>
|
|
{% endblock %}
|
|
|
|
{% block sidebar_tables %}
|
|
{{ macros.sidebar_table_links(tables, tableName) }}
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
|
|
{# Op Tabs #}
|
|
<nav class="op-tabs">
|
|
<a href="{{ path('table_browse', {name: tableName}) }}" class="op-tab active">
|
|
<i class="bi bi-grid-3x3-gap"></i> Browse
|
|
</a>
|
|
<a href="{{ path('table_structure', {name: tableName}) }}" class="op-tab">
|
|
<i class="bi bi-diagram-3"></i> Structure
|
|
</a>
|
|
<a href="{{ path('table_query', {name: tableName}) }}" class="op-tab">
|
|
<i class="bi bi-search"></i> Query
|
|
</a>
|
|
<a href="{{ path('table_item_new', {name: tableName}) }}" class="op-tab">
|
|
<i class="bi bi-plus-circle"></i> Insert
|
|
</a>
|
|
<span style="flex:1;"></span>
|
|
{# Drop table — in a confirmation modal #}
|
|
<button class="op-tab" style="color:#ef4444;" @click="showDrop = true">
|
|
<i class="bi bi-trash3"></i> Drop Table
|
|
</button>
|
|
</nav>
|
|
|
|
{# Error #}
|
|
{% if error %}
|
|
<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;border-radius:6px;padding:0.75rem 1rem;margin-bottom:1rem;font-size:0.8rem;">
|
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# Stats row #}
|
|
{% if not error %}
|
|
<div class="d-flex align-items-center gap-2 mb-3 flex-wrap">
|
|
<span class="stat-pill"><i class="bi bi-table"></i> {{ tableName }}</span>
|
|
<span class="stat-pill"><i class="bi bi-check-circle" style="color:var(--jormun-teal);"></i> {{ (desc.TableStatus ?? 'UNKNOWN')|lower }}</span>
|
|
<span class="stat-pill"><i class="bi bi-collection"></i> ~{{ (desc.ItemCount ?? 0)|number_format }} items</span>
|
|
<span class="stat-pill"><i class="bi bi-eye"></i> showing {{ count }} of {{ scannedCount }} scanned</span>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# Main data card #}
|
|
<div class="content-card" x-data="browseTable()">
|
|
<div class="content-card-header">
|
|
<h6><i class="bi bi-grid-3x3-gap me-1" style="color:var(--jormun-teal);"></i> Rows</h6>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<label style="font-size:0.72rem;color:#64748b;">Rows per page</label>
|
|
<select class="jormun-select" onchange="changeLimit(this.value)" style="width:auto;">
|
|
{% set limit = limit is defined ? limit : 25 %}
|
|
{% for l in [10, 25, 50, 100] %}
|
|
<option value="{{ l }}" {% if l == limit %}selected{% endif %}>{{ l }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{% if items is empty %}
|
|
<div class="empty-state">
|
|
<i class="bi bi-inbox"></i>
|
|
<div style="font-family:'Syne',sans-serif;font-weight:700;font-size:0.9rem;color:#374151;margin-bottom:0.3rem;">No items found</div>
|
|
<div style="font-size:0.75rem;">This table is empty.</div>
|
|
</div>
|
|
{% else %}
|
|
<div style="overflow-x:auto;">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th style="width:100px;">Actions</th>
|
|
{% for col in columns %}
|
|
<th>
|
|
{{ col }}
|
|
{% if keySchema.HASH is defined and keySchema.HASH.name == col %}
|
|
<span class="type-badge" title="Partition Key">PK</span>
|
|
{% endif %}
|
|
{% if keySchema.RANGE is defined and keySchema.RANGE.name == col %}
|
|
<span class="type-badge" title="Sort Key">SK</span>
|
|
{% endif %}
|
|
</th>
|
|
{% endfor %}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for item in items %}
|
|
{% set itemKey = {} %}
|
|
{% if keySchema.HASH is defined %}
|
|
{% set itemKey = itemKey|merge({(keySchema.HASH.name): item[keySchema.HASH.name]}) %}
|
|
{% endif %}
|
|
{% if keySchema.RANGE is defined %}
|
|
{% set itemKey = itemKey|merge({(keySchema.RANGE.name): item[keySchema.RANGE.name]}) %}
|
|
{% endif %}
|
|
<tr>
|
|
<td class="action-cell">
|
|
<a href="{{ path('table_item_edit', {name: tableName, key: itemKey|json_encode}) }}" class="btn-sm-edit">
|
|
<i class="bi bi-pencil"></i>
|
|
</a>
|
|
<button
|
|
class="btn-danger-sm ms-1"
|
|
@click="confirmDelete($el.dataset.key)"
|
|
data-key="{{ itemKey|json_encode|e('html_attr') }}"
|
|
>
|
|
<i class="bi bi-trash3"></i>
|
|
</button>
|
|
</td>
|
|
{% for col in columns %}
|
|
<td class="{% if keySchema.HASH is defined and keySchema.HASH.name == col %}pk-cell{% endif %} cell-value" title="{{ item[col] is defined ? item[col]|json_encode|e('html_attr') : 'null' }}">
|
|
{% if item[col] is defined %}
|
|
{% set val = item[col] %}
|
|
{% if val is iterable %}
|
|
<span style="color:#6366f1;" title="{{ val|json_encode|e('html_attr') }}">
|
|
<i class="bi bi-braces"></i> {{ val|json_encode|slice(0, 40) }}{% if val|json_encode|length > 40 %}…{% endif %}
|
|
</span>
|
|
{% else %}
|
|
{% set valStr = val is iterable ? val|json_encode : val|e %}
|
|
{{ valStr|slice(0, 60) }}{% if valStr|length > 60 %}…{% endif %}
|
|
{% endif %}
|
|
{% else %}
|
|
<span class="null-val">NULL</span>
|
|
{% endif %}
|
|
</td>
|
|
{% endfor %}
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{# Pagination #}
|
|
<div style="padding:0.65rem 1rem;border-top:1px solid #f1f5f9;display:flex;align-items:center;gap:0.5rem;justify-content:space-between;">
|
|
<div style="font-size:0.72rem;color:#64748b;">
|
|
Showing {{ count }} item{{ count != 1 ? 's' : '' }}
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
{% if prevKeys is not empty %}
|
|
{% set prevStack = prevKeys[0:(prevKeys|length - 1)] %}
|
|
{% set prevLastKey = prevKeys|last %}
|
|
<a href="{{ path('table_browse', {name: tableName, lastKey: prevLastKey, prevKeys: prevStack|json_encode, limit: limit}) }}" class="btn-jormun-outline">
|
|
<i class="bi bi-chevron-left"></i> Prev
|
|
</a>
|
|
{% endif %}
|
|
|
|
{% if nextKey %}
|
|
{% set newPrevKeys = prevKeys %}
|
|
{% if currentKey %}
|
|
{% set newPrevKeys = newPrevKeys|merge([currentKey]) %}
|
|
{% else %}
|
|
{# first page, push empty marker #}
|
|
{% set newPrevKeys = newPrevKeys|merge(['']) %}
|
|
{% endif %}
|
|
<a href="{{ path('table_browse', {name: tableName, lastKey: nextKey, prevKeys: newPrevKeys|json_encode, limit: limit}) }}" class="btn-jormun">
|
|
Next <i class="bi bi-chevron-right"></i>
|
|
</a>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# Delete item modal #}
|
|
<div x-show="deleteModal" x-cloak style="position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9000;display:flex;align-items:center;justify-content:center;" @click.self="deleteModal=false">
|
|
<div style="background:#fff;border-radius:8px;padding:1.5rem;width:420px;max-width:90vw;" @click.stop>
|
|
<h6 class="font-display" style="font-weight:700;margin-bottom:0.5rem;">
|
|
<i class="bi bi-trash3-fill" style="color:#ef4444;"></i> Delete Item
|
|
</h6>
|
|
<p style="font-size:0.8rem;color:#64748b;margin-bottom:1rem;">This will permanently delete the item with key:</p>
|
|
<pre style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:4px;padding:0.5rem;font-size:0.72rem;margin-bottom:1rem;" x-text="JSON.stringify(deleteKey, null, 2)"></pre>
|
|
<div class="d-flex gap-2 justify-content-end">
|
|
<button class="btn-jormun-outline" @click="deleteModal=false">Cancel</button>
|
|
<form method="POST" :action="deleteAction">
|
|
<input type="hidden" name="key" :value="JSON.stringify(deleteKey)">
|
|
<button type="submit" class="btn-jormun" style="background:#ef4444;">
|
|
<i class="bi bi-trash3"></i> Delete
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# Drop Table modal #}
|
|
<div x-data="{ showDrop: false }" x-cloak>
|
|
<div x-show="showDrop" style="position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9000;display:flex;align-items:center;justify-content:center;" @click.self="showDrop=false">
|
|
<div style="background:#fff;border-radius:8px;padding:1.5rem;width:400px;max-width:90vw;">
|
|
<h6 class="font-display" style="font-weight:700;margin-bottom:0.5rem;color:#ef4444;">
|
|
<i class="bi bi-exclamation-triangle-fill"></i> Drop Table
|
|
</h6>
|
|
<p style="font-size:0.8rem;color:#374151;margin-bottom:1rem;">
|
|
Are you sure you want to drop <strong>{{ tableName }}</strong>? This action is <strong>irreversible</strong> and will delete all data.
|
|
</p>
|
|
<div class="d-flex gap-2 justify-content-end">
|
|
<button class="btn-jormun-outline" @click="showDrop=false">Cancel</button>
|
|
<form method="POST" action="{{ path('table_drop', {name: tableName}) }}">
|
|
<button type="submit" class="btn-jormun" style="background:#ef4444;">
|
|
<i class="bi bi-trash3-fill"></i> Drop Table
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
function browseTable() {
|
|
return {
|
|
deleteModal: false,
|
|
deleteKey: null,
|
|
deleteAction: '',
|
|
|
|
confirmDelete(keyJson) {
|
|
this.deleteKey = JSON.parse(keyJson);
|
|
this.deleteAction = '{{ path('table_item_delete', {name: tableName}) }}';
|
|
this.deleteModal = true;
|
|
}
|
|
};
|
|
}
|
|
|
|
function changeLimit(val) {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set('limit', val);
|
|
url.searchParams.delete('lastKey');
|
|
url.searchParams.delete('prevKeys');
|
|
window.location.href = url.toString();
|
|
}
|
|
</script>
|
|
{% endblock %}
|