feat: обновление с Git из админки (/admin/system)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1257,3 +1257,53 @@ body:has(.cookie-banner) .main {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.badge--warn {
|
||||
background: rgba(253, 203, 110, 0.15);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.admin-system__meta {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-system__path {
|
||||
font-size: 0.85rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.admin-system__actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.admin-system__hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.admin-system__form {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.admin-system__pre {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.admin-system__log {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.admin-system__help {
|
||||
margin-top: 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ const { requireAdmin } = require('../middleware/auth');
|
||||
const { asyncHandler } = require('../utils/asyncHandler');
|
||||
const { ROLE_LABELS } = require('../constants/roles');
|
||||
const { notifyIfBackInStock } = require('../services/stock-alerts');
|
||||
const gitDeploy = require('../services/git-deploy');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -349,4 +350,69 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/system',
|
||||
asyncHandler(async (req, res) => {
|
||||
const fetchRemote =
|
||||
req.query.checked === '1' || req.query.done === '1' || req.query.failed === '1';
|
||||
|
||||
let updateLog = null;
|
||||
let updateOk = false;
|
||||
let updateFail = false;
|
||||
let updateCode = null;
|
||||
|
||||
if (req.query.done === '1' || req.query.failed === '1') {
|
||||
updateLog = req.session.adminUpdateLog || null;
|
||||
updateOk = req.session.adminUpdateOk === true;
|
||||
updateFail = req.session.adminUpdateOk === false;
|
||||
updateCode = req.session.adminUpdateCode ?? null;
|
||||
delete req.session.adminUpdateLog;
|
||||
delete req.session.adminUpdateOk;
|
||||
delete req.session.adminUpdateCode;
|
||||
}
|
||||
|
||||
const git = await gitDeploy.getGitInfo({ fetchRemote: !!fetchRemote });
|
||||
res.render('admin/system', {
|
||||
title: 'Обновление',
|
||||
git,
|
||||
updateLog,
|
||||
updateOk,
|
||||
updateFail,
|
||||
updateCode,
|
||||
confirmError: req.query.error === 'confirm',
|
||||
disabledError: req.query.error === 'disabled',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/system/check',
|
||||
asyncHandler(async (req, res) => {
|
||||
res.redirect('/admin/system?checked=1');
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/system/update',
|
||||
asyncHandler(async (req, res) => {
|
||||
if (!gitDeploy.isUpdateEnabled()) {
|
||||
return res.redirect('/admin/system?error=disabled');
|
||||
}
|
||||
const confirm = (req.body.confirm || '').trim().toLowerCase();
|
||||
if (confirm !== 'update') {
|
||||
return res.redirect('/admin/system?error=confirm');
|
||||
}
|
||||
|
||||
const result = await gitDeploy.runDeployUpdate();
|
||||
req.session.adminUpdateLog = result.output;
|
||||
req.session.adminUpdateOk = result.ok;
|
||||
req.session.adminUpdateCode = result.code;
|
||||
|
||||
if (result.ok) {
|
||||
return res.redirect('/admin/system?done=1');
|
||||
}
|
||||
return res.redirect('/admin/system?failed=1');
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
const { execFile, spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { promisify } = require('util');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function resolveShopRoot() {
|
||||
const candidates = [
|
||||
process.env.SHOP_ROOT,
|
||||
path.resolve(__dirname, '../..'),
|
||||
'/opt/shop',
|
||||
].filter(Boolean);
|
||||
|
||||
for (const dir of candidates) {
|
||||
const resolved = path.resolve(dir);
|
||||
if (fs.existsSync(path.join(resolved, 'package.json'))) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isUpdateEnabled() {
|
||||
if (process.env.ADMIN_UPDATE_ENABLED === '0') return false;
|
||||
const root = resolveShopRoot();
|
||||
if (!root) return false;
|
||||
if (!fs.existsSync(path.join(root, '.git'))) return false;
|
||||
if (process.platform === 'win32') return false;
|
||||
return fs.existsSync(path.join(root, 'scripts', 'admin-web-update.sh'));
|
||||
}
|
||||
|
||||
async function gitCmd(args, cwd) {
|
||||
const { stdout, stderr } = await execFileAsync('git', args, {
|
||||
cwd,
|
||||
maxBuffer: 1024 * 1024,
|
||||
timeout: 90000,
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||
});
|
||||
return `${stdout}${stderr}`.trim();
|
||||
}
|
||||
|
||||
async function getGitInfo({ fetchRemote = false } = {}) {
|
||||
const root = resolveShopRoot();
|
||||
const pkg = root ? JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) : null;
|
||||
|
||||
if (!root || !fs.existsSync(path.join(root, '.git'))) {
|
||||
return {
|
||||
available: false,
|
||||
packageVersion: pkg?.version || null,
|
||||
shopRoot: root,
|
||||
reason: 'Каталог не является git-репозиторием. Задайте SHOP_ROOT в .env.',
|
||||
};
|
||||
}
|
||||
|
||||
const info = {
|
||||
available: true,
|
||||
shopRoot: root,
|
||||
packageVersion: pkg?.version || null,
|
||||
branch: null,
|
||||
commit: null,
|
||||
commitShort: null,
|
||||
commitSubject: null,
|
||||
commitDate: null,
|
||||
dirty: false,
|
||||
behind: null,
|
||||
updateEnabled: isUpdateEnabled(),
|
||||
platform: process.platform,
|
||||
};
|
||||
|
||||
try {
|
||||
info.branch = await gitCmd(['branch', '--show-current'], root);
|
||||
if (!info.branch) {
|
||||
info.branch = '(detached)';
|
||||
info.commitShort = await gitCmd(['rev-parse', '--short', 'HEAD'], root);
|
||||
}
|
||||
info.commit = await gitCmd(['rev-parse', 'HEAD'], root);
|
||||
info.commitShort = info.commitShort || (await gitCmd(['rev-parse', '--short', 'HEAD'], root));
|
||||
info.commitSubject = await gitCmd(['log', '-1', '--pretty=%s'], root);
|
||||
info.commitDate = await gitCmd(['log', '-1', '--pretty=%ci'], root);
|
||||
const status = await gitCmd(['status', '--porcelain'], root);
|
||||
info.dirty = status.length > 0;
|
||||
} catch (err) {
|
||||
info.available = false;
|
||||
info.reason = err.message;
|
||||
return info;
|
||||
}
|
||||
|
||||
if (fetchRemote) {
|
||||
try {
|
||||
await gitCmd(['fetch', 'origin'], root);
|
||||
const behind = await gitCmd(
|
||||
['rev-list', '--count', 'HEAD..origin/main'],
|
||||
root
|
||||
);
|
||||
info.behind = parseInt(behind, 10) || 0;
|
||||
} catch (err) {
|
||||
info.fetchError = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
function runDeployUpdate() {
|
||||
const root = resolveShopRoot();
|
||||
const scriptPath = path.join(root, 'scripts', 'admin-web-update.sh');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const useSudo = process.env.ADMIN_UPDATE_USE_SUDO === '1';
|
||||
const cmd = useSudo ? 'sudo' : 'bash';
|
||||
const args = useSudo ? ['-n', scriptPath] : [scriptPath];
|
||||
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: root,
|
||||
env: { ...process.env, SHOP_ROOT: root },
|
||||
timeout: 300000,
|
||||
});
|
||||
|
||||
let output = '';
|
||||
child.stdout.on('data', (chunk) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
resolve({
|
||||
ok: false,
|
||||
code: -1,
|
||||
output: `${output}\n${err.message}`.trim(),
|
||||
});
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
resolve({
|
||||
ok: code === 0,
|
||||
code: code ?? 1,
|
||||
output: output.trim() || '(нет вывода)',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveShopRoot,
|
||||
isUpdateEnabled,
|
||||
getGitInfo,
|
||||
runDeployUpdate,
|
||||
};
|
||||
@@ -2,17 +2,15 @@
|
||||
|
||||
<div class="admin-header">
|
||||
<h1>Админ-панель</h1>
|
||||
<nav class="admin-nav">
|
||||
<a href="/admin" class="admin-nav__link admin-nav__link--active">Обзор</a>
|
||||
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
|
||||
<a href="/admin/users" class="admin-nav__link">Пользователи</a>
|
||||
<a href="/admin/products" class="admin-nav__link">Товары</a>
|
||||
<a href="/admin/promo-codes" class="admin-nav__link">Промокоды</a>
|
||||
<a href="/admin/reservations" class="admin-nav__link">Бронирования</a>
|
||||
<a href="/" class="admin-nav__link">В магазин</a>
|
||||
</nav>
|
||||
<%- include('../partials/admin-nav', { adminNav: 'dashboard' }) %>
|
||||
</div>
|
||||
|
||||
<section class="card" style="margin-bottom:1.5rem">
|
||||
<h2 style="margin-top:0">Обновление с Git</h2>
|
||||
<p class="muted">Подтянуть новую версию и перезапустить магазин без SSH.</p>
|
||||
<a href="/admin/system" class="btn btn--primary">Перейти к обновлению →</a>
|
||||
</section>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-card__label">Пользователи</span>
|
||||
|
||||
@@ -2,15 +2,7 @@
|
||||
|
||||
<div class="admin-header">
|
||||
<h1>Заказы</h1>
|
||||
<nav class="admin-nav">
|
||||
<a href="/admin" class="admin-nav__link">Обзор</a>
|
||||
<a href="/admin/orders" class="admin-nav__link admin-nav__link--active">Заказы</a>
|
||||
<a href="/admin/users" class="admin-nav__link">Пользователи</a>
|
||||
<a href="/admin/products" class="admin-nav__link">Товары</a>
|
||||
<a href="/admin/promo-codes" class="admin-nav__link">Промокоды</a>
|
||||
<a href="/admin/reservations" class="admin-nav__link">Бронирования</a>
|
||||
<a href="/" class="admin-nav__link">В магазин</a>
|
||||
</nav>
|
||||
<%- include('../partials/admin-nav', { adminNav: 'orders' }) %>
|
||||
</div>
|
||||
|
||||
<% const statusLabels = { pending: 'Ожидает', paid: 'Оплачен', shipped: 'Отправлен', cancelled: 'Отменён' }; %>
|
||||
|
||||
@@ -2,15 +2,7 @@
|
||||
|
||||
<div class="admin-header">
|
||||
<h1>Товары — цены и скидки</h1>
|
||||
<nav class="admin-nav">
|
||||
<a href="/admin" class="admin-nav__link">Обзор</a>
|
||||
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
|
||||
<a href="/admin/users" class="admin-nav__link">Пользователи</a>
|
||||
<a href="/admin/products" class="admin-nav__link admin-nav__link--active">Товары</a>
|
||||
<a href="/admin/promo-codes" class="admin-nav__link">Промокоды</a>
|
||||
<a href="/admin/reservations" class="admin-nav__link">Бронирования</a>
|
||||
<a href="/" class="admin-nav__link">В магазин</a>
|
||||
</nav>
|
||||
<%- include('../partials/admin-nav', { adminNav: 'products' }) %>
|
||||
</div>
|
||||
|
||||
<% if (stockUpdated) { %>
|
||||
|
||||
@@ -2,15 +2,7 @@
|
||||
|
||||
<div class="admin-header">
|
||||
<h1>Промокоды и скидки</h1>
|
||||
<nav class="admin-nav">
|
||||
<a href="/admin" class="admin-nav__link">Обзор</a>
|
||||
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
|
||||
<a href="/admin/users" class="admin-nav__link">Пользователи</a>
|
||||
<a href="/admin/products" class="admin-nav__link">Товары</a>
|
||||
<a href="/admin/promo-codes" class="admin-nav__link admin-nav__link--active">Промокоды</a>
|
||||
<a href="/admin/reservations" class="admin-nav__link">Бронирования</a>
|
||||
<a href="/" class="admin-nav__link">В магазин</a>
|
||||
</nav>
|
||||
<%- include('../partials/admin-nav', { adminNav: 'promo' }) %>
|
||||
</div>
|
||||
|
||||
<% if (created) { %><p class="alert alert--success">Промокод создан</p><% } %>
|
||||
|
||||
@@ -2,15 +2,7 @@
|
||||
|
||||
<div class="admin-header">
|
||||
<h1>Бронирования</h1>
|
||||
<nav class="admin-nav">
|
||||
<a href="/admin" class="admin-nav__link">Обзор</a>
|
||||
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
|
||||
<a href="/admin/users" class="admin-nav__link">Пользователи</a>
|
||||
<a href="/admin/products" class="admin-nav__link">Товары</a>
|
||||
<a href="/admin/promo-codes" class="admin-nav__link">Промокоды</a>
|
||||
<a href="/admin/reservations" class="admin-nav__link admin-nav__link--active">Бронирования</a>
|
||||
<a href="/" class="admin-nav__link">В магазин</a>
|
||||
</nav>
|
||||
<%- include('../partials/admin-nav', { adminNav: 'reservations' }) %>
|
||||
</div>
|
||||
|
||||
<% const resStatus = { active: 'Активна', fulfilled: 'Выполнена', cancelled: 'Отменена', expired: 'Истекла' }; %>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<%- include('../partials/layout-start') %>
|
||||
|
||||
<div class="admin-header">
|
||||
<h1>Обновление с Git</h1>
|
||||
<%- include('../partials/admin-nav', { adminNav: 'system' }) %>
|
||||
</div>
|
||||
|
||||
<% if (updateOk) { %>
|
||||
<p class="alert alert--success">Обновление выполнено успешно. Если сайт не открывается — подождите 10–20 сек и обновите страницу.</p>
|
||||
<% } %>
|
||||
<% if (updateFail) { %>
|
||||
<p class="alert alert--error">Обновление завершилось с ошибкой (код <%= updateCode %>).</p>
|
||||
<% } %>
|
||||
<% if (confirmError) { %>
|
||||
<p class="alert alert--error">Введите <strong>update</strong> для подтверждения.</p>
|
||||
<% } %>
|
||||
<% if (disabledError) { %>
|
||||
<p class="alert alert--warn">Обновление из админки отключено на этом сервере.</p>
|
||||
<% } %>
|
||||
|
||||
<section class="card admin-system">
|
||||
<h2>Текущая версия</h2>
|
||||
<% if (!git.available) { %>
|
||||
<p class="alert alert--warn"><%= git.reason || 'Git недоступен' %></p>
|
||||
<% } else { %>
|
||||
<dl class="profile-dl admin-system__meta">
|
||||
<dt>Версия приложения</dt>
|
||||
<dd><strong>v<%= git.packageVersion || '?' %></strong></dd>
|
||||
<dt>Каталог</dt>
|
||||
<dd><code class="admin-system__path"><%= git.shopRoot %></code></dd>
|
||||
<dt>Ветка</dt>
|
||||
<dd><%= git.branch %></dd>
|
||||
<dt>Коммит</dt>
|
||||
<dd>
|
||||
<code><%= git.commitShort %></code>
|
||||
— <%= git.commitSubject %>
|
||||
<span class="muted">(<%= git.commitDate %>)</span>
|
||||
</dd>
|
||||
<% if (git.dirty) { %>
|
||||
<dt>Состояние</dt>
|
||||
<dd><span class="badge badge--warn">Есть незакоммиченные изменения</span></dd>
|
||||
<% } %>
|
||||
<% if (git.behind != null) { %>
|
||||
<dt>На origin/main</dt>
|
||||
<dd>
|
||||
<% if (git.behind > 0) { %>
|
||||
<span class="badge badge--sale">Доступно обновлений: <%= git.behind %></span>
|
||||
<% } else { %>
|
||||
<span class="badge">Актуально</span>
|
||||
<% } %>
|
||||
</dd>
|
||||
<% } %>
|
||||
<% if (git.fetchError) { %>
|
||||
<dt>origin</dt>
|
||||
<dd class="muted">Не удалось проверить: <%= git.fetchError %></dd>
|
||||
<% } %>
|
||||
</dl>
|
||||
<% } %>
|
||||
|
||||
<div class="admin-system__actions">
|
||||
<form action="/admin/system/check" method="post" class="inline-form">
|
||||
<button type="submit" class="btn btn--ghost">
|
||||
<%- include('../partials/icon', { name: 'refresh', iconSize: 18 }) %>
|
||||
Проверить на Git
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<% if (git.updateEnabled) { %>
|
||||
<hr class="admin-system__hr">
|
||||
<h2>Обновить сейчас</h2>
|
||||
<p class="muted admin-hint">
|
||||
Выполняется <code>git pull</code>, <code>npm install</code> и перезапуск службы <code>shop</code>.
|
||||
Страница может оборваться на несколько секунд — это нормально.
|
||||
</p>
|
||||
<form action="/admin/system/update" method="post" class="form admin-system__form" onsubmit="return confirm('Обновить код с Git и перезапустить магазин?');">
|
||||
<label class="label">
|
||||
Подтверждение: введите <strong>update</strong>
|
||||
<input type="text" name="confirm" class="input" required autocomplete="off" placeholder="update">
|
||||
</label>
|
||||
<button type="submit" class="btn btn--primary btn--lg">
|
||||
<%- include('../partials/icon', { name: 'download', iconSize: 20 }) %>
|
||||
Обновить с Git
|
||||
</button>
|
||||
</form>
|
||||
<% } else if (git.available) { %>
|
||||
<p class="alert alert--warn">
|
||||
Обновление из админки отключено (Windows, нет .git или <code>ADMIN_UPDATE_ENABLED=0</code>).
|
||||
На сервере: <code>bash "$SHOP_ROOT/scripts/server-update.sh"</code>
|
||||
</p>
|
||||
<% } %>
|
||||
</section>
|
||||
|
||||
<% if (updateLog) { %>
|
||||
<section class="card admin-system__log">
|
||||
<h2>Журнал обновления</h2>
|
||||
<pre class="admin-system__pre"><%= updateLog %></pre>
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
<section class="card admin-system__help muted">
|
||||
<h2>Настройка сервера</h2>
|
||||
<p>В <code>.env</code>: <code>SHOP_ROOT=/opt/shop</code>, <code>ADMIN_UPDATE_ENABLED=1</code>.</p>
|
||||
<p>Если служба работает от <code>www-data</code>, добавьте в sudoers (от root):</p>
|
||||
<pre class="admin-system__pre">www-data ALL=(root) NOPASSWD: <%= git.shopRoot || '/opt/shop' %>/scripts/admin-web-update.sh</pre>
|
||||
<p>И в .env: <code>ADMIN_UPDATE_USE_SUDO=1</code></p>
|
||||
</section>
|
||||
|
||||
<%- include('../partials/layout-end') %>
|
||||
@@ -2,15 +2,7 @@
|
||||
|
||||
<div class="admin-header">
|
||||
<h1>Пользователи</h1>
|
||||
<nav class="admin-nav">
|
||||
<a href="/admin" class="admin-nav__link">Обзор</a>
|
||||
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
|
||||
<a href="/admin/users" class="admin-nav__link admin-nav__link--active">Пользователи</a>
|
||||
<a href="/admin/products" class="admin-nav__link">Товары</a>
|
||||
<a href="/admin/promo-codes" class="admin-nav__link">Промокоды</a>
|
||||
<a href="/admin/reservations" class="admin-nav__link">Бронирования</a>
|
||||
<a href="/" class="admin-nav__link">В магазин</a>
|
||||
</nav>
|
||||
<%- include('../partials/admin-nav', { adminNav: 'users' }) %>
|
||||
</div>
|
||||
|
||||
<p class="muted admin-hint">
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<%
|
||||
const nav = typeof adminNav !== 'undefined' ? adminNav : '';
|
||||
function navClass(id) {
|
||||
return 'admin-nav__link' + (nav === id ? ' admin-nav__link--active' : '');
|
||||
}
|
||||
%>
|
||||
<nav class="admin-nav">
|
||||
<a href="/admin" class="<%= navClass('dashboard') %>">Обзор</a>
|
||||
<a href="/admin/orders" class="<%= navClass('orders') %>">Заказы</a>
|
||||
<a href="/admin/users" class="<%= navClass('users') %>">Пользователи</a>
|
||||
<a href="/admin/products" class="<%= navClass('products') %>">Товары</a>
|
||||
<a href="/admin/promo-codes" class="<%= navClass('promo') %>">Промокоды</a>
|
||||
<a href="/admin/reservations" class="<%= navClass('reservations') %>">Бронирования</a>
|
||||
<a href="/admin/system" class="<%= navClass('system') %>">Обновление</a>
|
||||
<a href="/" class="admin-nav__link">В магазин</a>
|
||||
</nav>
|
||||
@@ -20,6 +20,10 @@
|
||||
<svg class="<%= cls %>" width="<%= sz %>" height="<%= sz %>" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
<% } else if (name === 'arrow-left') { %>
|
||||
<svg class="<%= cls %>" width="<%= sz %>" height="<%= sz %>" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m12 19-7-7 7-7M19 12H5"/></svg>
|
||||
<% } else if (name === 'refresh') { %>
|
||||
<svg class="<%= cls %>" width="<%= sz %>" height="<%= sz %>" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>
|
||||
<% } else if (name === 'download') { %>
|
||||
<svg class="<%= cls %>" width="<%= sz %>" height="<%= sz %>" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<% } else if (name === 'package') { %>
|
||||
<svg class="<%= cls %>" width="<%= sz %>" height="<%= sz %>" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16.5 9.4 7.55 4.24M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
|
||||
<% } %>
|
||||
|
||||
Reference in New Issue
Block a user