Édition du docker-compose.yml depuis la page d'un conteneur
Vérification par docker compose config avant écriture, application avec docker compose up -d et retour à la version précédente en cas d'échec. Historique des versions par fichier. Le Caddyfile et le compose gardent un retour à la ligne final. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dcdac915f9
commit
c106fcfc62
27 files changed
+1035
-58
No files matched your search
@@ -30,6 +30,12 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./pages/containers/container-detail').then((m) => m.ContainerDetailPage),
|
||||
},
|
||||
{
|
||||
path: 'containers/:id/compose',
|
||||
title: 'docker-compose.yml · Big Brother',
|
||||
loadComponent: () =>
|
||||
import('./pages/containers/container-compose').then((m) => m.ContainerComposePage),
|
||||
},
|
||||
{
|
||||
path: 'sites',
|
||||
title: 'Sites · Big Brother',
|
||||
|
||||
@@ -8,6 +8,9 @@ import type {
|
||||
BackupSchedule,
|
||||
CaddyInfo,
|
||||
CaddyVersion,
|
||||
ComposeInfo,
|
||||
ComposeSaveResult,
|
||||
ComposeVersion,
|
||||
ContainerAction,
|
||||
ContainerDetails,
|
||||
ContainerSummary,
|
||||
@@ -82,6 +85,19 @@ export class Api {
|
||||
containerAction(id: string, action: ContainerAction) {
|
||||
return this.send<ContainerDetails>('POST', `/containers/${encodeURIComponent(id)}/${action}`);
|
||||
}
|
||||
compose(id: string) {
|
||||
return this.get<ComposeInfo>(`/containers/${enc(id)}/compose`);
|
||||
}
|
||||
composeVersion(id: string, versionId: number) {
|
||||
return this.get<ComposeVersion>(`/containers/${enc(id)}/compose/versions/${versionId}`);
|
||||
}
|
||||
saveCompose(id: string, content: string, reason: string, apply: boolean) {
|
||||
return this.send<ComposeSaveResult>('PUT', `/containers/${enc(id)}/compose`, {
|
||||
content,
|
||||
reason,
|
||||
apply,
|
||||
});
|
||||
}
|
||||
|
||||
// Sites
|
||||
sites() {
|
||||
|
||||
@@ -246,6 +246,39 @@ export interface CaddyVersion {
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface ComposeVersion {
|
||||
id: number;
|
||||
username: string | null;
|
||||
reason: string | null;
|
||||
createdAt: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface ComposeInfo {
|
||||
project: string;
|
||||
service: string | null;
|
||||
workingDir: string;
|
||||
file: string;
|
||||
/**
|
||||
* Containers of the same compose project.
|
||||
*/
|
||||
containers: string[];
|
||||
editable: boolean;
|
||||
readOnlyReason: string | null;
|
||||
content: string;
|
||||
versions: ComposeVersion[];
|
||||
}
|
||||
|
||||
export interface ComposeSaveResult {
|
||||
ok: true;
|
||||
changed: boolean;
|
||||
output: string;
|
||||
/**
|
||||
* Set when "docker compose up -d" was started.
|
||||
*/
|
||||
jobId: number | null;
|
||||
}
|
||||
|
||||
export interface JobLine {
|
||||
ts: number;
|
||||
stream: 'out' | 'err' | 'info';
|
||||
|
||||
@@ -1,43 +1,4 @@
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.editor {
|
||||
min-height: 520px;
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.save-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.output {
|
||||
max-height: 240px;
|
||||
margin: 8px 0 0;
|
||||
overflow: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.site-list,
|
||||
.version-list {
|
||||
.site-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
@@ -55,17 +16,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.version-list li {
|
||||
gap: 8px;
|
||||
|
||||
.muted {
|
||||
display: block;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.version-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { Icon } from '../../shared/icon';
|
||||
selector: 'app-caddy',
|
||||
imports: [FormsModule, Icon],
|
||||
templateUrl: './caddy.html',
|
||||
styleUrl: './caddy.scss',
|
||||
styleUrls: ['../../shared/editor-page.scss', './caddy.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CaddyPage {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<a [routerLink]="['/containers', id()]" class="back-link"
|
||||
><app-icon name="back" /> {{ id() }}</a
|
||||
>
|
||||
<h1>{{ fileName() }}</h1>
|
||||
@if (info(); as i) {
|
||||
<p>
|
||||
Projet compose <strong>{{ i.project }}</strong> ·
|
||||
<span class="mono">{{ i.file }}</span>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert critical" role="alert">
|
||||
<app-icon name="critical" />
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (jobId(); as job) {
|
||||
<app-job-console [jobId]="job" (finished)="onFinished($event)" />
|
||||
}
|
||||
|
||||
@if (info(); as i) {
|
||||
@if (!i.editable) {
|
||||
<div class="alert info">
|
||||
<app-icon name="lock" />
|
||||
<p>{{ i.readOnlyReason }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="layout">
|
||||
<section class="card editor-card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>{{ fileName() }}</h2>
|
||||
<p>{{ lineCount() }} lignes{{ dirty() ? ' · modifications non enregistrées' : '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body stack">
|
||||
<textarea
|
||||
class="textarea editor"
|
||||
spellcheck="false"
|
||||
autocapitalize="off"
|
||||
[attr.aria-label]="'Contenu de ' + fileName()"
|
||||
[readOnly]="!i.editable"
|
||||
[ngModel]="content()"
|
||||
(ngModelChange)="content.set($event)"
|
||||
(keydown)="onKeydown($event)"
|
||||
></textarea>
|
||||
|
||||
@if (output(); as out) {
|
||||
<div class="alert" [class.info]="out.ok" [class.critical]="!out.ok" role="status">
|
||||
<app-icon [name]="out.ok ? 'warning' : 'critical'" />
|
||||
<div>
|
||||
<p>
|
||||
{{
|
||||
out.ok
|
||||
? 'docker compose a accepté le fichier, avec ces remarques :'
|
||||
: 'docker compose a refusé le fichier : rien n’a été modifié.'
|
||||
}}
|
||||
</p>
|
||||
<pre class="output">{{ out.text }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (i.editable) {
|
||||
<div class="save-row">
|
||||
<input
|
||||
class="input"
|
||||
placeholder="Motif (facultatif), ex. : port retiré"
|
||||
[ngModel]="reason()"
|
||||
(ngModelChange)="reason.set($event)"
|
||||
aria-label="Motif de la modification"
|
||||
/>
|
||||
<button type="button" class="btn" (click)="reset()" [disabled]="!dirty() || busy()">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
(click)="save(false)"
|
||||
[disabled]="!dirty() || busy()"
|
||||
>
|
||||
<app-icon name="save" /> Enregistrer
|
||||
</button>
|
||||
<button type="button" class="btn primary" (click)="save(true)" [disabled]="busy()">
|
||||
@if (busy()) {
|
||||
<app-icon name="loader" class="spin" />
|
||||
} @else {
|
||||
<app-icon name="play" />
|
||||
}
|
||||
{{ dirty() ? 'Enregistrer et appliquer' : 'Appliquer' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Le fichier est toujours vérifié par <span class="mono">docker compose config</span>
|
||||
avant d’être écrit. « Enregistrer » ne touche pas aux conteneurs : les changements
|
||||
s’appliquent au prochain démarrage du projet. « Appliquer » lance
|
||||
<span class="mono">docker compose up -d</span> tout de suite.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="stack">
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>Conteneurs du projet</h2>
|
||||
<p>Ceux que l’application peut recréer</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body flush">
|
||||
<ul class="container-list">
|
||||
@for (name of i.containers; track name) {
|
||||
<li>
|
||||
<a [routerLink]="['/containers', name]">{{ name }}</a>
|
||||
@if (name === id()) {
|
||||
<span class="pill">ce conteneur</span>
|
||||
}
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="muted">Aucun conteneur en cours</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>Versions précédentes</h2>
|
||||
<p>Sauvegardées avant chaque modification</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body flush">
|
||||
<ul class="version-list">
|
||||
@for (version of i.versions; track version.id) {
|
||||
<li>
|
||||
<div>
|
||||
<strong>#{{ version.id }}</strong> · {{ dateTime(version.createdAt) }}
|
||||
<span class="muted"
|
||||
>{{ version.reason ?? ''
|
||||
}}{{ version.username ? ' · ' + version.username : '' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="version-actions">
|
||||
<button type="button" class="btn small" (click)="view(version)">Voir</button>
|
||||
</div>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="muted">
|
||||
Pas encore de version (elles apparaissent après la première modification depuis le
|
||||
tableau de bord).
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@if (viewed(); as version) {
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<h2>Version #{{ version.id }} · {{ dateTime(version.createdAt) }}</h2>
|
||||
<div class="toolbar">
|
||||
@if (i.editable) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn small"
|
||||
(click)="loadVersion(version)"
|
||||
[disabled]="busy()"
|
||||
>
|
||||
Charger dans l’éditeur
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn small ghost" (click)="viewed.set(null)">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre class="code-block">{{ version.content }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.container-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Api } from '../../core/api';
|
||||
import { Feedback } from '../../core/feedback';
|
||||
import { errorMessage, formatDateTime } from '../../core/format';
|
||||
import type { AuditStatus, ComposeInfo, ComposeVersion } from '../../core/models';
|
||||
import { Icon } from '../../shared/icon';
|
||||
import { JobConsole } from '../../shared/job-console';
|
||||
|
||||
/**
|
||||
* The textarea gives back "\n" line breaks: the file is compared the same way.
|
||||
*/
|
||||
function normalize(content: string) {
|
||||
return content.replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-container-compose',
|
||||
imports: [FormsModule, RouterLink, Icon, JobConsole],
|
||||
templateUrl: './container-compose.html',
|
||||
styleUrls: ['../../shared/editor-page.scss', './container-compose.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ContainerComposePage {
|
||||
private api = inject(Api);
|
||||
private feedback = inject(Feedback);
|
||||
|
||||
/**
|
||||
* Container name or id (route parameter).
|
||||
*/
|
||||
readonly id = input.required<string>();
|
||||
|
||||
protected readonly info = signal<ComposeInfo | null>(null);
|
||||
protected readonly content = signal('');
|
||||
protected readonly reason = signal('');
|
||||
protected readonly busy = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly output = signal<{ ok: boolean; text: string } | null>(null);
|
||||
protected readonly viewed = signal<ComposeVersion | null>(null);
|
||||
protected readonly jobId = signal<number | null>(null);
|
||||
|
||||
protected readonly dirty = computed(() => {
|
||||
const info = this.info();
|
||||
return info !== null && this.content() !== normalize(info.content);
|
||||
});
|
||||
protected readonly lineCount = computed(() => this.content().split('\n').length);
|
||||
protected readonly fileName = computed(
|
||||
() => this.info()?.file.split(/[\\/]/).pop() ?? 'docker-compose.yml',
|
||||
);
|
||||
protected readonly dateTime = formatDateTime;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const id = this.id();
|
||||
untracked(() => this.load(id));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* keepContent: after a failed apply, the edited text stays in the editor.
|
||||
*/
|
||||
protected async load(id = this.id(), keepContent = false) {
|
||||
try {
|
||||
const info = await this.api.compose(id);
|
||||
this.info.set(info);
|
||||
if (!keepContent) this.content.set(normalize(info.content));
|
||||
this.error.set(null);
|
||||
} catch (error) {
|
||||
this.error.set(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
protected async save(apply: boolean) {
|
||||
const info = this.info();
|
||||
if (!info) return;
|
||||
|
||||
if (apply) {
|
||||
const others = info.containers.length > 1 ? ` (${info.containers.join(', ')})` : '';
|
||||
const ok = await this.feedback.confirm({
|
||||
title: this.dirty() ? 'Enregistrer et appliquer ?' : 'Appliquer le fichier ?',
|
||||
message: `« docker compose up -d » recrée les conteneurs du projet ${info.project}${others} dont la configuration a changé : ils sont coupés quelques secondes. Si la commande échoue, la version précédente est remise en place et relancée.`,
|
||||
confirmLabel: this.dirty() ? 'Enregistrer et appliquer' : 'Appliquer',
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
this.busy.set(true);
|
||||
this.output.set(null);
|
||||
this.jobId.set(null);
|
||||
try {
|
||||
const result = await this.api.saveCompose(
|
||||
this.id(),
|
||||
this.content(),
|
||||
this.reason().trim(),
|
||||
apply,
|
||||
);
|
||||
this.reason.set('');
|
||||
if (result.output && result.output !== 'Aucun changement.') {
|
||||
this.output.set({ ok: true, text: result.output });
|
||||
}
|
||||
if (result.jobId !== null) {
|
||||
/**
|
||||
* The page stays busy until the job console reports the end.
|
||||
*/
|
||||
this.jobId.set(result.jobId);
|
||||
return;
|
||||
}
|
||||
this.feedback.success(
|
||||
result.changed ? `${this.fileName()} enregistré.` : 'Aucun changement.',
|
||||
);
|
||||
await this.load();
|
||||
} catch (error) {
|
||||
const body = error instanceof HttpErrorResponse ? error.error : null;
|
||||
this.output.set({ ok: false, text: body?.output || errorMessage(error) });
|
||||
this.feedback.error(errorMessage(error));
|
||||
} finally {
|
||||
if (this.jobId() === null) this.busy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async onFinished(status: AuditStatus) {
|
||||
this.busy.set(false);
|
||||
if (status === 'success') {
|
||||
this.feedback.success(`${this.fileName()} appliqué.`);
|
||||
await this.load();
|
||||
} else {
|
||||
this.feedback.error('L’application a échoué : le détail est dans la console.');
|
||||
await this.load(this.id(), true);
|
||||
}
|
||||
}
|
||||
|
||||
protected reset() {
|
||||
this.content.set(normalize(this.info()?.content ?? ''));
|
||||
this.output.set(null);
|
||||
}
|
||||
|
||||
protected async view(version: ComposeVersion) {
|
||||
try {
|
||||
this.viewed.set(await this.api.composeVersion(this.id(), version.id));
|
||||
} catch (error) {
|
||||
this.feedback.error(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
protected loadVersion(version: ComposeVersion) {
|
||||
this.content.set(normalize(version.content ?? ''));
|
||||
this.viewed.set(null);
|
||||
this.output.set(null);
|
||||
this.feedback.info(
|
||||
`Version #${version.id} chargée dans l’éditeur : enregistre pour la remettre.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab inserts two spaces: YAML does not accept tabs for indentation.
|
||||
*/
|
||||
protected onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Tab' || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
const textarea = event.target as HTMLTextAreaElement;
|
||||
const { selectionStart, selectionEnd, value } = textarea;
|
||||
const next = `${value.slice(0, selectionStart)} ${value.slice(selectionEnd)}`;
|
||||
this.content.set(next);
|
||||
textarea.value = next;
|
||||
textarea.selectionStart = textarea.selectionEnd = selectionStart + 2;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,11 @@
|
||||
</div>
|
||||
@if (container(); as c) {
|
||||
<div class="toolbar">
|
||||
@if (c.composeProject) {
|
||||
<a class="btn" [routerLink]="['/containers', c.name, 'compose']">
|
||||
<app-icon name="file" /> docker-compose.yml
|
||||
</a>
|
||||
}
|
||||
@if (c.state === 'running') {
|
||||
<button type="button" class="btn" (click)="act('restart')" [disabled]="busy()">
|
||||
<app-icon name="restart" /> Redémarrer
|
||||
|
||||
@@ -232,6 +232,11 @@
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<h2>{{ p.composeFile }}</h2>
|
||||
@if (p.containers.length) {
|
||||
<a class="btn small" [routerLink]="['/containers', p.containers[0].name, 'compose']">
|
||||
<app-icon name="file" /> Modifier
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre class="code-block">{{ p.compose }}</pre>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Pages with a file editor and its previous versions (Caddyfile, docker-compose.yml).
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.editor {
|
||||
min-height: 520px;
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.save-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.output {
|
||||
max-height: 240px;
|
||||
margin: 8px 0 0;
|
||||
overflow: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.version-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.muted {
|
||||
display: block;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.version-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -58,6 +58,7 @@ const ICONS = {
|
||||
table:
|
||||
'<path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/>',
|
||||
chart: '<path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="m19 9-5 5-4-4-3 3"/>',
|
||||
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof ICONS;
|
||||
|
||||
Reference in new issue
Block a user