353253
This commit is contained in:
@@ -80,13 +80,16 @@ export class MetadataService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTable(userId: string, payload: { tableName: string; columns: { name: string; type: string; nullable?: boolean }[] }) {
|
async createTable(
|
||||||
|
userId: string,
|
||||||
|
payload: { tableName: string; columns: { name: string; type: string; nullable?: boolean; primaryKey?: boolean }[] }
|
||||||
|
) {
|
||||||
await this.assertSchemaPermission(userId, payload.tableName);
|
await this.assertSchemaPermission(userId, payload.tableName);
|
||||||
|
|
||||||
const columnSql = payload.columns
|
const columnSql = payload.columns
|
||||||
.map(
|
.map(
|
||||||
(column: { name: string; type: string; nullable?: boolean }) =>
|
(column: { name: string; type: string; nullable?: boolean; primaryKey?: boolean }) =>
|
||||||
`${quoteIdentifier(column.name)} ${column.type}${column.nullable ? "" : " NOT NULL"}`
|
`${quoteIdentifier(column.name)} ${column.type}${column.primaryKey ? " PRIMARY KEY" : column.nullable ? "" : " NOT NULL"}`
|
||||||
)
|
)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export const createTableSchema = z.object({
|
|||||||
z.object({
|
z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
type: z.string().min(1),
|
type: z.string().min(1),
|
||||||
nullable: z.boolean().optional()
|
nullable: z.boolean().optional(),
|
||||||
|
primaryKey: z.boolean().optional()
|
||||||
})
|
})
|
||||||
).min(1)
|
).min(1)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export const api = {
|
|||||||
login: (body) => request("/auth/login", { method: "POST", body: JSON.stringify(body) }),
|
login: (body) => request("/auth/login", { method: "POST", body: JSON.stringify(body) }),
|
||||||
logout: () => request("/auth/logout", { method: "POST" }),
|
logout: () => request("/auth/logout", { method: "POST" }),
|
||||||
tables: () => request("/db/tables"),
|
tables: () => request("/db/tables"),
|
||||||
|
createTable: (body) => request("/db/tables", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
deleteTable: (tableName) => request(`/db/tables/${tableName}`, { method: "DELETE" }),
|
||||||
tableDetails: (tableName) => request(`/db/tables/${tableName}/details`),
|
tableDetails: (tableName) => request(`/db/tables/${tableName}/details`),
|
||||||
rows: (tableName, query = {}) => {
|
rows: (tableName, query = {}) => {
|
||||||
const params = new URLSearchParams(query).toString();
|
const params = new URLSearchParams(query).toString();
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export function renderAppShell(state) {
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
${renderCreateTableModal(state)}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +202,7 @@ function renderActiveTab(state, activeTableMeta) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderDataTab(state) {
|
function renderDataTab(state) {
|
||||||
|
const filteredRows = applyColumnFilters(state.rows.rows || [], state.columnFilters);
|
||||||
return `
|
return `
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -214,10 +216,41 @@ function renderDataTab(state) {
|
|||||||
<option value="asc" ${state.sortDirection === "asc" ? "selected" : ""}>ASC</option>
|
<option value="asc" ${state.sortDirection === "asc" ? "selected" : ""}>ASC</option>
|
||||||
<option value="desc" ${state.sortDirection === "desc" ? "selected" : ""}>DESC</option>
|
<option value="desc" ${state.sortDirection === "desc" ? "selected" : ""}>DESC</option>
|
||||||
</select>
|
</select>
|
||||||
|
<button class="secondary-button" data-action="toggle-column-filters">Колонковые фильтры</button>
|
||||||
<button class="secondary-button" data-action="search">Применить</button>
|
<button class="secondary-button" data-action="search">Применить</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${
|
||||||
|
state.showColumnFilters
|
||||||
|
? `
|
||||||
|
<section class="subpanel filter-panel">
|
||||||
|
<div class="subpanel-header">
|
||||||
|
<h4>Фильтры по колонкам</h4>
|
||||||
|
</div>
|
||||||
|
<div class="filter-grid">
|
||||||
|
${state.tableDetails.columns
|
||||||
|
.map(
|
||||||
|
(column) => `
|
||||||
|
<label>
|
||||||
|
<span>${column.column_name}</span>
|
||||||
|
<input
|
||||||
|
data-input-action="set-column-filter"
|
||||||
|
data-action="set-column-filter"
|
||||||
|
data-column="${column.column_name}"
|
||||||
|
value="${escapeHtml(state.columnFilters[column.column_name] || "")}"
|
||||||
|
placeholder="Фильтр"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
|
||||||
<div class="panel-grid">
|
<div class="panel-grid">
|
||||||
<section class="subpanel">
|
<section class="subpanel">
|
||||||
<div class="subpanel-header">
|
<div class="subpanel-header">
|
||||||
@@ -254,7 +287,7 @@ function renderDataTab(state) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-toolbar">
|
<div class="table-toolbar">
|
||||||
<div class="muted">Всего строк: ${state.rows.total}</div>
|
<div class="muted">Всего строк: ${state.rows.total} · После фильтров: ${filteredRows.length}</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button class="secondary-button" data-action="prev-page" ${state.pageNumber <= 1 ? "disabled" : ""}>Назад</button>
|
<button class="secondary-button" data-action="prev-page" ${state.pageNumber <= 1 ? "disabled" : ""}>Назад</button>
|
||||||
<div class="page-pill">Страница ${state.pageNumber}</div>
|
<div class="page-pill">Страница ${state.pageNumber}</div>
|
||||||
@@ -262,7 +295,7 @@ function renderDataTab(state) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${renderRowsTable(state.rows.rows || [], {
|
${renderRowsTable(filteredRows, {
|
||||||
selectable: true,
|
selectable: true,
|
||||||
selectedRowId: state.selectedRow?.id,
|
selectedRowId: state.selectedRow?.id,
|
||||||
allowDelete: true
|
allowDelete: true
|
||||||
@@ -452,6 +485,14 @@ function renderSidePanel(state, activeTableMeta) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="side-card">
|
||||||
|
<div class="eyebrow">Schema Actions</div>
|
||||||
|
<div class="quick-sql-list">
|
||||||
|
<button class="primary-button" data-action="open-create-table-modal">Создать таблицу</button>
|
||||||
|
<button class="danger-button" data-action="delete-table" ${activeTableMeta ? "" : "disabled"}>Удалить таблицу</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="side-card">
|
<section class="side-card">
|
||||||
<div class="eyebrow">Quick SQL</div>
|
<div class="eyebrow">Quick SQL</div>
|
||||||
<div class="quick-sql-list">
|
<div class="quick-sql-list">
|
||||||
@@ -484,6 +525,60 @@ function renderSidePanel(state, activeTableMeta) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderCreateTableModal(state) {
|
||||||
|
if (!state.isCreateTableModalOpen) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="modal-backdrop">
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<div class="hero-kicker">Create Table</div>
|
||||||
|
<h3>Создание таблицы</h3>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button" data-action="close-create-table-modal">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="createTableForm" class="form-grid">
|
||||||
|
<label>
|
||||||
|
<span>Название таблицы</span>
|
||||||
|
<input name="tableName" placeholder="finance_entries_archive" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="subpanel">
|
||||||
|
<div class="subpanel-header">
|
||||||
|
<h4>Колонки</h4>
|
||||||
|
<button class="secondary-button" type="button" data-action="add-table-column">Добавить колонку</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-column-builder">
|
||||||
|
${state.newTableColumns
|
||||||
|
.map(
|
||||||
|
(column) => `
|
||||||
|
<div class="builder-row">
|
||||||
|
<input name="column-name-${column.id}" value="${escapeHtml(column.name)}" placeholder="name" required />
|
||||||
|
<input name="column-type-${column.id}" value="${escapeHtml(column.type)}" placeholder="varchar(255)" required />
|
||||||
|
<label class="checkbox-field inline-check"><input type="checkbox" name="column-primary-${column.id}" ${column.primaryKey ? "checked" : ""} /><span>PK</span></label>
|
||||||
|
<label class="checkbox-field inline-check"><input type="checkbox" name="column-nullable-${column.id}" ${column.nullable ? "checked" : ""} /><span>NULL</span></label>
|
||||||
|
<button class="danger-button small-button" type="button" data-action="remove-table-column" data-column-id="${column.id}">Удалить</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions split">
|
||||||
|
<button class="secondary-button" type="button" data-action="close-create-table-modal">Отмена</button>
|
||||||
|
<button class="primary-button" type="submit">Создать таблицу</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderSortOptions(state) {
|
function renderSortOptions(state) {
|
||||||
const columns = state.tableDetails.columns || [];
|
const columns = state.tableDetails.columns || [];
|
||||||
|
|
||||||
@@ -564,6 +659,20 @@ function renderRowsTable(rows, options = {}) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyColumnFilters(rows, columnFilters) {
|
||||||
|
if (!Object.keys(columnFilters).length) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.filter((row) =>
|
||||||
|
Object.entries(columnFilters).every(([column, value]) =>
|
||||||
|
String(row[column] ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(String(value).toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatCell(value) {
|
function formatCell(value) {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return `<span class="cell-null">null</span>`;
|
return `<span class="cell-null">null</span>`;
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ const state = {
|
|||||||
sortDirection: "asc",
|
sortDirection: "asc",
|
||||||
pageNumber: 1,
|
pageNumber: 1,
|
||||||
pageSize: 25,
|
pageSize: 25,
|
||||||
selectedRow: null
|
selectedRow: null,
|
||||||
|
columnFilters: {},
|
||||||
|
showColumnFilters: false,
|
||||||
|
isCreateTableModalOpen: false,
|
||||||
|
newTableColumns: [{ id: 1, name: "", type: "varchar(255)", nullable: true, primaryKey: false }]
|
||||||
};
|
};
|
||||||
|
|
||||||
const app = document.querySelector("#app");
|
const app = document.querySelector("#app");
|
||||||
@@ -112,6 +116,7 @@ function bindEvents() {
|
|||||||
document.querySelector("#addColumnForm")?.addEventListener("submit", wrapAction(handleAddColumn));
|
document.querySelector("#addColumnForm")?.addEventListener("submit", wrapAction(handleAddColumn));
|
||||||
document.querySelector("#alterColumnForm")?.addEventListener("submit", wrapAction(handleAlterColumn));
|
document.querySelector("#alterColumnForm")?.addEventListener("submit", wrapAction(handleAlterColumn));
|
||||||
document.querySelector("#createIndexForm")?.addEventListener("submit", wrapAction(handleCreateIndex));
|
document.querySelector("#createIndexForm")?.addEventListener("submit", wrapAction(handleCreateIndex));
|
||||||
|
document.querySelector("#createTableForm")?.addEventListener("submit", wrapAction(handleCreateTable));
|
||||||
|
|
||||||
document.querySelectorAll("[data-page]").forEach((element) => {
|
document.querySelectorAll("[data-page]").forEach((element) => {
|
||||||
element.addEventListener(
|
element.addEventListener(
|
||||||
@@ -149,6 +154,10 @@ function bindEvents() {
|
|||||||
document.querySelectorAll("[data-action]").forEach((element) => {
|
document.querySelectorAll("[data-action]").forEach((element) => {
|
||||||
element.addEventListener("click", wrapAction(handleAction));
|
element.addEventListener("click", wrapAction(handleAction));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-input-action='set-column-filter']").forEach((element) => {
|
||||||
|
element.addEventListener("input", wrapAction(handleAction));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function wrapAction(handler) {
|
function wrapAction(handler) {
|
||||||
@@ -209,6 +218,11 @@ async function handleAction(event) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === "toggle-column-filters") {
|
||||||
|
state.showColumnFilters = !state.showColumnFilters;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (action === "run-sql") {
|
if (action === "run-sql") {
|
||||||
state.sqlDraft = document.querySelector("#sqlEditor")?.value || "";
|
state.sqlDraft = document.querySelector("#sqlEditor")?.value || "";
|
||||||
const result = await api.executeSql(state.sqlDraft);
|
const result = await api.executeSql(state.sqlDraft);
|
||||||
@@ -287,6 +301,62 @@ async function handleAction(event) {
|
|||||||
const payload = await api.postgresLogs(state.logsSearch);
|
const payload = await api.postgresLogs(state.logsSearch);
|
||||||
state.postgresLogs = payload.logs;
|
state.postgresLogs = payload.logs;
|
||||||
setNotice("Логи отфильтрованы");
|
setNotice("Логи отфильтрованы");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "set-column-filter") {
|
||||||
|
const column = event.currentTarget.dataset.column;
|
||||||
|
const value = event.currentTarget.value.trim();
|
||||||
|
if (value) {
|
||||||
|
state.columnFilters[column] = value;
|
||||||
|
} else {
|
||||||
|
delete state.columnFilters[column];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "open-create-table-modal") {
|
||||||
|
state.isCreateTableModalOpen = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "close-create-table-modal") {
|
||||||
|
state.isCreateTableModalOpen = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "add-table-column") {
|
||||||
|
state.newTableColumns.push({
|
||||||
|
id: Date.now(),
|
||||||
|
name: "",
|
||||||
|
type: "varchar(255)",
|
||||||
|
nullable: true,
|
||||||
|
primaryKey: false
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "remove-table-column") {
|
||||||
|
const columnId = Number(event.currentTarget.dataset.columnId);
|
||||||
|
state.newTableColumns = state.newTableColumns.filter((column) => column.id !== columnId);
|
||||||
|
if (!state.newTableColumns.length) {
|
||||||
|
state.newTableColumns = [{ id: Date.now(), name: "", type: "varchar(255)", nullable: true, primaryKey: false }];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "delete-table") {
|
||||||
|
if (!state.activeTable) {
|
||||||
|
throw new Error("Сначала выберите таблицу");
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.deleteTable(state.activeTable);
|
||||||
|
state.activeTable = "";
|
||||||
|
state.selectedRow = null;
|
||||||
|
state.tableDetails = { columns: [], foreignKeys: [], indexes: [] };
|
||||||
|
state.rows = { rows: [], total: 0 };
|
||||||
|
await hydrateDashboard();
|
||||||
|
setNotice("Таблица удалена");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +422,35 @@ async function handleCreateIndex(event) {
|
|||||||
setNotice("Индекс создан");
|
setNotice("Индекс создан");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCreateTable(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const tableName = String(formData.get("tableName") || "").trim();
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
|
throw new Error("Введите название таблицы");
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = state.newTableColumns.map((column) => ({
|
||||||
|
name: String(formData.get(`column-name-${column.id}`) || "").trim(),
|
||||||
|
type: String(formData.get(`column-type-${column.id}`) || "").trim(),
|
||||||
|
nullable: formData.get(`column-nullable-${column.id}`) === "on",
|
||||||
|
primaryKey: formData.get(`column-primary-${column.id}`) === "on"
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (columns.some((column) => !column.name || !column.type)) {
|
||||||
|
throw new Error("У каждой колонки должны быть имя и тип");
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.createTable({ tableName, columns });
|
||||||
|
state.isCreateTableModalOpen = false;
|
||||||
|
state.newTableColumns = [{ id: Date.now(), name: "", type: "varchar(255)", nullable: true, primaryKey: false }];
|
||||||
|
await hydrateDashboard();
|
||||||
|
state.activeTable = tableName;
|
||||||
|
await refreshTableState();
|
||||||
|
setNotice("Таблица создана");
|
||||||
|
}
|
||||||
|
|
||||||
function formToPayload(form) {
|
function formToPayload(form) {
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
const payload = {};
|
const payload = {};
|
||||||
|
|||||||
@@ -451,6 +451,16 @@ textarea {
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-panel {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.subpanel {
|
.subpanel {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
@@ -473,6 +483,10 @@ textarea {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-check {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.checkbox-field input {
|
.checkbox-field input {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
@@ -567,6 +581,48 @@ td {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 80;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(2, 9, 19, 0.72);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
width: min(980px, 100%);
|
||||||
|
max-height: 88vh;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 28px;
|
||||||
|
border: 1px solid rgba(137, 181, 255, 0.16);
|
||||||
|
background: rgba(10, 21, 38, 0.98);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-column-builder {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.3fr 1.2fr auto auto auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.table-toolbar {
|
.table-toolbar {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin: 18px 0;
|
margin: 18px 0;
|
||||||
@@ -614,7 +670,8 @@ button:disabled {
|
|||||||
.hero-card,
|
.hero-card,
|
||||||
.section-header,
|
.section-header,
|
||||||
.subpanel-header,
|
.subpanel-header,
|
||||||
.table-toolbar {
|
.table-toolbar,
|
||||||
|
.modal-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
@@ -633,4 +690,8 @@ button:disabled {
|
|||||||
.login-form {
|
.login-form {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.builder-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1452
index.html
Normal file
1452
index.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user