feat: use vanilla js
This commit is contained in:
15
Dockerfile
15
Dockerfile
@@ -16,26 +16,13 @@ COPY internal/ /app/internal/
|
||||
RUN go build -o ddbb ./cmd/ddbb/main.go
|
||||
|
||||
|
||||
################
|
||||
# Build frontend
|
||||
FROM node:24-alpine as js-builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY ./web/package*.json ./web/tsconfig.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY web/src/ ./src/
|
||||
RUN npx tsc
|
||||
|
||||
######################
|
||||
# Production container
|
||||
FROM alpine:latest
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=go-builder /app/ddbb .
|
||||
COPY --from=js-builder /app/dist ./dist/static/
|
||||
COPY ./web/index.html /app/dist/index.html
|
||||
COPY ./web/ /app/
|
||||
|
||||
RUN apk update
|
||||
RUN apk add bash mariadb-client postgresql17-client mongodb-tools
|
||||
|
||||
@@ -133,9 +133,9 @@ func SetupRouter() *gin.Engine {
|
||||
}
|
||||
|
||||
// GIN endpoints
|
||||
r.Static("/static", "./dist/static")
|
||||
r.Static("/static", "./static")
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
c.File("./dist/index.html")
|
||||
c.File("./index.html")
|
||||
})
|
||||
r.GET("/list", listJobs)
|
||||
r.POST("/dump", triggerDump)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var outFileRoot = "/backups/"
|
||||
@@ -26,8 +27,21 @@ func NewDumpJob(job types.Job) (types.DumpJobResult, error) {
|
||||
|
||||
if err != nil {
|
||||
log.Printf("%v", err)
|
||||
return types.DumpJobResult{
|
||||
Id: job.Id,
|
||||
TimeStart: time.Now(),
|
||||
TimeEnd: time.Now(),
|
||||
Result: "Failure",
|
||||
Error: err,
|
||||
}, err
|
||||
}
|
||||
return types.DumpJobResult{}, nil
|
||||
return types.DumpJobResult{
|
||||
Id: job.Id,
|
||||
TimeStart: time.Now(),
|
||||
TimeEnd: time.Now(),
|
||||
Result: "Success",
|
||||
Error: nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func dumpMariaDB(job types.Job) error {
|
||||
|
||||
@@ -7,22 +7,41 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var dumpFileRoot = "/backups/"
|
||||
|
||||
func NewRestoreJob(job types.Job) (types.RestoreJobResult, error) {
|
||||
var err error
|
||||
switch strings.ToLower(job.Kind) {
|
||||
case "mariadb":
|
||||
restoreMariaDB(job)
|
||||
err = restoreMariaDB(job)
|
||||
case "postgres":
|
||||
restorePostgres(job)
|
||||
err = restorePostgres(job)
|
||||
case "mongodb":
|
||||
restoreMongo(job)
|
||||
err = restoreMongo(job)
|
||||
default:
|
||||
log.Printf("unsupported database kind: %s", job.Kind)
|
||||
}
|
||||
return types.RestoreJobResult{}, nil
|
||||
|
||||
if err != nil {
|
||||
log.Printf("%v", err)
|
||||
return types.RestoreJobResult{
|
||||
Id: job.Id,
|
||||
TimeStart: time.Now(),
|
||||
TimeEnd: time.Now(),
|
||||
Result: "Failure",
|
||||
Error: err,
|
||||
}, err
|
||||
}
|
||||
return types.RestoreJobResult{
|
||||
Id: job.Id,
|
||||
TimeStart: time.Now(),
|
||||
TimeEnd: time.Now(),
|
||||
Result: "Success",
|
||||
Error: nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func restoreMariaDB(job types.Job) error {
|
||||
|
||||
@@ -1,65 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Jobs Dashboard</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Fira Mono', monospace;
|
||||
background-color: #1e1e2f;
|
||||
color: #c5c8c6;
|
||||
margin: 20px;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-top: 15px;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #283046;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
th {
|
||||
background: #2e2e42;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
user-select: none;
|
||||
}
|
||||
th.sort-asc::after {
|
||||
content: ' ▲';
|
||||
font-size: 0.8em;
|
||||
}
|
||||
th.sort-desc::after {
|
||||
content: ' ▼';
|
||||
font-size: 0.8em;
|
||||
}
|
||||
input.filter {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
background: #2e2e42;
|
||||
border: none;
|
||||
color: #c5c8c6;
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
button {
|
||||
font-family: 'Fira Mono', monospace;
|
||||
background-color: #444c67;
|
||||
border: none;
|
||||
color: #c5c8c6;
|
||||
padding: 6px 10px;
|
||||
margin-right: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #66729e;
|
||||
}
|
||||
</style>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Docker Database Backup</title>
|
||||
<script type="module" src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.esm.js"></script>
|
||||
<script nomodule src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ionic/core/css/ionic.bundle.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/static/app.js"></script>
|
||||
<div id="app"></div>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1076
web/package-lock.json
generated
1076
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "ddbb-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "Frontend for ddbb app",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "serve ./dist"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"serve": "^14.2.5",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
174
web/src/app.ts
174
web/src/app.ts
@@ -1,174 +0,0 @@
|
||||
interface Job {
|
||||
id: string;
|
||||
kind: string;
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
user: string;
|
||||
dumpFile: string;
|
||||
integrityStatus: string;
|
||||
}
|
||||
|
||||
class JobsTable {
|
||||
private jobs: Job[] = [];
|
||||
private filteredJobs: Job[] = [];
|
||||
private sortCol: keyof Job | null = null;
|
||||
private sortAsc: boolean = true;
|
||||
|
||||
private filters: Partial<Record<keyof Job, string>> = {};
|
||||
|
||||
constructor(private container: HTMLElement) {}
|
||||
|
||||
async loadData() {
|
||||
const response = await fetch('/list');
|
||||
this.jobs = await response.json();
|
||||
this.filteredJobs = [...this.jobs];
|
||||
this.render();
|
||||
}
|
||||
|
||||
private applyFilters() {
|
||||
this.filteredJobs = this.jobs.filter(job => {
|
||||
return Object.entries(this.filters).every(([key, filterStr]) =>
|
||||
job[key as keyof Job]
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.includes(filterStr.toLowerCase())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private applySort() {
|
||||
if (!this.sortCol) return;
|
||||
this.filteredJobs.sort((a, b) => {
|
||||
const va = a[this.sortCol!];
|
||||
const vb = b[this.sortCol!];
|
||||
if (va < vb) return this.sortAsc ? -1 : 1;
|
||||
if (va > vb) return this.sortAsc ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
private renderFilters(headers: (keyof Job)[]) {
|
||||
const filterRow = document.createElement('tr');
|
||||
headers.forEach(col => {
|
||||
const th = document.createElement('th');
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'filter';
|
||||
input.placeholder = 'Filter...';
|
||||
input.oninput = (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const val = target.value;
|
||||
if (val) this.filters[col] = val;
|
||||
else delete this.filters[col];
|
||||
this.applyFilters();
|
||||
this.applySort();
|
||||
this.renderTableBody();
|
||||
};
|
||||
th.appendChild(input);
|
||||
filterRow.appendChild(th);
|
||||
});
|
||||
return filterRow;
|
||||
}
|
||||
|
||||
private renderTableBody() {
|
||||
const tbody = this.container.querySelector('tbody')!;
|
||||
tbody.innerHTML = '';
|
||||
this.filteredJobs.forEach(job => {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
tr.innerHTML = `
|
||||
<td>${job.id}</td>
|
||||
<td>${job.kind}</td>
|
||||
<td>${job.host}</td>
|
||||
<td>${job.port}</td>
|
||||
<td>${job.database}</td>
|
||||
<td>${job.user}</td>
|
||||
<td>${job.dumpFile}</td>
|
||||
<td>${job.integrityStatus}</td>
|
||||
<td>
|
||||
<button data-action="dump" data-id="${job.id}">Dump</button>
|
||||
<button data-action="restore" data-id="${job.id}">Restore</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
// Ping buttons with click handlers
|
||||
tr.querySelector('button[data-action="dump"]')!
|
||||
.addEventListener('click', () => this.triggerAction(job.id, 'dump'));
|
||||
tr.querySelector('button[data-action="restore"]')!
|
||||
.addEventListener('click', () => this.triggerAction(job.id, 'restore'));
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
private triggerAction(jobId: string, action: 'dump' | 'restore') {
|
||||
fetch(`/${action}`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({id: jobId})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
alert(`${action.toUpperCase()} Result: ${res.result ?? 'Unknown'}`);
|
||||
this.loadData();
|
||||
})
|
||||
.catch(() => alert('Error running ' + action));
|
||||
}
|
||||
|
||||
private render() {
|
||||
const headers: (keyof Job)[] = [
|
||||
'id', 'kind', 'host', 'port', 'database', 'user', 'dumpFile', 'integrityStatus'
|
||||
];
|
||||
|
||||
this.container.innerHTML = '';
|
||||
const table = document.createElement('table');
|
||||
|
||||
// Filter inputs row
|
||||
table.appendChild(this.renderFilters(headers));
|
||||
|
||||
// Header row
|
||||
const tr = document.createElement('tr');
|
||||
headers.forEach(col => {
|
||||
const th = document.createElement('th');
|
||||
th.textContent = col.charAt(0).toUpperCase() + col.slice(1);
|
||||
th.style.userSelect = 'none';
|
||||
|
||||
if (this.sortCol === col) {
|
||||
th.classList.add(this.sortAsc ? 'sort-asc' : 'sort-desc');
|
||||
}
|
||||
|
||||
th.addEventListener('click', () => {
|
||||
if (this.sortCol === col) {
|
||||
this.sortAsc = !this.sortAsc;
|
||||
} else {
|
||||
this.sortCol = col;
|
||||
this.sortAsc = true;
|
||||
}
|
||||
this.applySort();
|
||||
this.renderTableBody();
|
||||
this.render(); // To update headers with arrows
|
||||
});
|
||||
tr.appendChild(th);
|
||||
});
|
||||
// Extra empty header for action buttons
|
||||
tr.appendChild(document.createElement('th'));
|
||||
table.appendChild(tr);
|
||||
|
||||
// Body
|
||||
const tbody = document.createElement('tbody');
|
||||
table.appendChild(tbody);
|
||||
|
||||
this.container.appendChild(table);
|
||||
|
||||
this.renderTableBody();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const app = document.getElementById('app');
|
||||
if (!app) return;
|
||||
|
||||
const jobsTable = new JobsTable(app);
|
||||
jobsTable.loadData();
|
||||
});
|
||||
222
web/static/app.js
Normal file
222
web/static/app.js
Normal file
@@ -0,0 +1,222 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const app = document.getElementById('app');
|
||||
if (!app) return;
|
||||
|
||||
const ionApp = document.createElement('ion-app');
|
||||
const ionContent = document.createElement('ion-content');
|
||||
ionContent.classList.add('ion-padding');
|
||||
|
||||
const mainDiv = document.createElement('div');
|
||||
|
||||
ionContent.appendChild(mainDiv);
|
||||
ionApp.appendChild(ionContent);
|
||||
app.appendChild(ionApp);
|
||||
|
||||
let jobs = [];
|
||||
let filteredJobs = [];
|
||||
let filters = {};
|
||||
let sortField = null;
|
||||
let sortAsc = true;
|
||||
|
||||
const columns = ['id', 'kind', 'host', 'database', 'user'];
|
||||
const displayNames = ['ID', 'Kind', 'Host', 'Database', 'User'];
|
||||
|
||||
const headerGrid = document.createElement('ion-grid');
|
||||
const filterRow = document.createElement('ion-row');
|
||||
const headerRow = document.createElement('ion-row');
|
||||
|
||||
filterRow.style.alignItems = 'center';
|
||||
headerRow.style.alignItems = 'center';
|
||||
|
||||
columns.forEach((col, idx) => {
|
||||
// Filters row with ion-searchbar filter
|
||||
const filterCol = document.createElement('ion-col');
|
||||
filterCol.size = '2';
|
||||
|
||||
const filterInput = document.createElement('ion-searchbar');
|
||||
filterInput.placeholder = `Filter ${displayNames[idx]}`;
|
||||
filterInput.style.setProperty('--min-height', '32px');
|
||||
filterInput.value = filters[col] || '';
|
||||
filterInput.addEventListener('ionInput', e => {
|
||||
const val = e.target.value.trim().toLowerCase();
|
||||
if (val) filters[col] = val;
|
||||
else delete filters[col];
|
||||
applyFilterSortRender();
|
||||
});
|
||||
filterCol.appendChild(filterInput);
|
||||
filterRow.appendChild(filterCol);
|
||||
|
||||
// Header row labels with sorting
|
||||
const headerCol = document.createElement('ion-col');
|
||||
headerCol.size = '2';
|
||||
headerCol.style.fontWeight = 'bold';
|
||||
headerCol.style.cursor = 'pointer';
|
||||
headerCol.textContent = displayNames[idx];
|
||||
headerCol.addEventListener('click', () => {
|
||||
if (sortField === col) {
|
||||
sortAsc = !sortAsc;
|
||||
} else {
|
||||
sortField = col;
|
||||
sortAsc = true;
|
||||
}
|
||||
applyFilterSortRender();
|
||||
});
|
||||
if (sortField === col) {
|
||||
headerCol.textContent += sortAsc ? ' ▲' : ' ▼';
|
||||
}
|
||||
headerCol.classList.add('ion-text-center');
|
||||
headerRow.appendChild(headerCol);
|
||||
});
|
||||
|
||||
// Actions column header and filter column (empty)
|
||||
const actionsFilterCol = document.createElement('ion-col');
|
||||
actionsFilterCol.size = '2';
|
||||
filterRow.appendChild(actionsFilterCol);
|
||||
|
||||
const actionsHeaderCol = document.createElement('ion-col');
|
||||
actionsHeaderCol.size = '2';
|
||||
actionsHeaderCol.style.fontWeight = 'bold';
|
||||
actionsHeaderCol.textContent = 'Actions';
|
||||
actionsHeaderCol.classList.add('ion-text-center'); // Center the header text
|
||||
headerRow.appendChild(actionsHeaderCol);
|
||||
|
||||
headerGrid.appendChild(filterRow);
|
||||
headerGrid.appendChild(headerRow);
|
||||
|
||||
const ionList = document.createElement('ion-list');
|
||||
|
||||
mainDiv.appendChild(headerGrid);
|
||||
mainDiv.appendChild(ionList);
|
||||
|
||||
async function loadJobs() {
|
||||
try {
|
||||
const res = await fetch('/list');
|
||||
jobs = await res.json();
|
||||
applyFilterSortRender();
|
||||
} catch {
|
||||
await presentAlert('Failed to load jobs.');
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilterSortRender() {
|
||||
filteredJobs = jobs.filter(job =>
|
||||
Object.entries(filters).every(([k, v]) =>
|
||||
(job[k] || '').toLowerCase().includes(v)
|
||||
)
|
||||
);
|
||||
if (sortField) {
|
||||
filteredJobs.sort((a, b) => {
|
||||
if (a[sortField] < b[sortField]) return sortAsc ? -1 : 1;
|
||||
if (a[sortField] > b[sortField]) return sortAsc ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
renderJobList();
|
||||
}
|
||||
|
||||
// Show an Ionic alert with OK button
|
||||
async function presentAlert(message) {
|
||||
return new Promise(async (resolve) => {
|
||||
const alert = document.createElement('ion-alert');
|
||||
alert.header = 'Result';
|
||||
alert.message = message;
|
||||
alert.buttons = ['OK'];
|
||||
document.body.appendChild(alert);
|
||||
await alert.present();
|
||||
alert.addEventListener('didDismiss', () => {
|
||||
alert.remove();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Show Ionic confirmation alert, resolves boolean
|
||||
async function presentConfirm(message) {
|
||||
return new Promise(async (resolve) => {
|
||||
const alert = document.createElement('ion-alert');
|
||||
alert.header = 'Confirm';
|
||||
alert.message = message;
|
||||
alert.buttons = [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
handler: () => resolve(false),
|
||||
},
|
||||
{
|
||||
text: 'OK',
|
||||
handler: () => resolve(true),
|
||||
},
|
||||
];
|
||||
document.body.appendChild(alert);
|
||||
await alert.present();
|
||||
alert.addEventListener('didDismiss', () => {
|
||||
alert.remove();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderJobList() {
|
||||
ionList.innerHTML = '';
|
||||
filteredJobs.forEach(job => {
|
||||
const ionItem = document.createElement('ion-item');
|
||||
|
||||
const grid = document.createElement('ion-grid');
|
||||
const row = document.createElement('ion-row');
|
||||
|
||||
columns.forEach(col => {
|
||||
const colElem = document.createElement('ion-col');
|
||||
colElem.size = '2';
|
||||
colElem.textContent = job[col] || '';
|
||||
colElem.classList.add('ion-text-center'); // Center column text
|
||||
row.appendChild(colElem);
|
||||
});
|
||||
|
||||
const actionCol = document.createElement('ion-col');
|
||||
actionCol.size = '2';
|
||||
actionCol.classList.add('ion-text-center'); // Center alignment
|
||||
|
||||
const dumpBtn = document.createElement('ion-button');
|
||||
dumpBtn.color = 'primary';
|
||||
dumpBtn.size = 'small';
|
||||
dumpBtn.textContent = 'Dump';
|
||||
dumpBtn.addEventListener('click', () => triggerAction(job.id, 'dump'));
|
||||
|
||||
const restoreBtn = document.createElement('ion-button');
|
||||
restoreBtn.color = 'danger';
|
||||
restoreBtn.size = 'small';
|
||||
restoreBtn.textContent = 'Restore';
|
||||
restoreBtn.addEventListener('click', async () => {
|
||||
const confirmed = await presentConfirm(`Confirm restore job ${job.id}?`);
|
||||
if (confirmed) {
|
||||
triggerAction(job.id, 'restore');
|
||||
}
|
||||
});
|
||||
|
||||
actionCol.appendChild(dumpBtn);
|
||||
actionCol.appendChild(restoreBtn);
|
||||
row.appendChild(actionCol);
|
||||
|
||||
grid.appendChild(row);
|
||||
ionItem.appendChild(grid);
|
||||
|
||||
ionList.appendChild(ionItem);
|
||||
});
|
||||
}
|
||||
|
||||
async function triggerAction(jobId, action) {
|
||||
try {
|
||||
const res = await fetch(`/${action}`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({id: jobId}),
|
||||
});
|
||||
const result = await res.json();
|
||||
await presentAlert(`${action.toUpperCase()} result: ${result.result || 'Unknown'}`);
|
||||
loadJobs();
|
||||
} catch {
|
||||
await presentAlert(`Error running ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
loadJobs();
|
||||
});
|
||||
80
web/static/style.css
Normal file
80
web/static/style.css
Normal file
@@ -0,0 +1,80 @@
|
||||
/* Minimal iOS-like clean theme */
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||||
Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
background: #f9f9fa;
|
||||
color: #1c1c1e;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 12px;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
thead th {
|
||||
color: #6e6e73;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 10px 15px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
thead th.sort-asc::after {
|
||||
content: " ▲";
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
thead th.sort-desc::after {
|
||||
content: " ▼";
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
background: white;
|
||||
padding: 12px 15px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(60, 60, 67, 0.08);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
background: #f3f3f7;
|
||||
}
|
||||
|
||||
input.filter {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ccc;
|
||||
font-size: 14px;
|
||||
color: #1c1c1e;
|
||||
box-sizing: border-box;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background-color: #007aff;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 8px 14px;
|
||||
border-radius: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-right: 6px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: #005bb5;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
background-color: #004494;
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"lib": ["esnext", "dom"],
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user