mirror of
https://github.com/Fabio286/antares.git
synced 2025-04-19 12:37:20 +02:00
681 lines
21 KiB
Vue
681 lines
21 KiB
Vue
<template>
|
|
<div
|
|
class="tr"
|
|
:style="{height: itemHeight+'px'}"
|
|
>
|
|
<div
|
|
v-for="(col, cKey) in row"
|
|
v-show="cKey !== '_antares_id'"
|
|
:key="cKey"
|
|
class="td p-0"
|
|
:class="{selected: selectedCell === cKey}"
|
|
@click="selectRow($event, cKey)"
|
|
|
|
@contextmenu.prevent="openContext($event, { id: row._antares_id, orgField: cKey })"
|
|
>
|
|
<template v-if="cKey !== '_antares_id'">
|
|
<span
|
|
v-if="!isInlineEditor[cKey] && fields[cKey]"
|
|
class="cell-content"
|
|
:class="`${isNull(col)} ${typeClass(fields[cKey].type)}`"
|
|
@dblclick="editON(cKey)"
|
|
>{{ cutText(typeFormat(col, fields[cKey].type.toLowerCase(), fields[cKey].length) as string) }}</span>
|
|
<ForeignKeySelect
|
|
v-else-if="isForeignKey(cKey)"
|
|
v-model="editingContent"
|
|
class="editable-field"
|
|
:key-usage="getKeyUsage(cKey)"
|
|
size="small"
|
|
@blur="editOFF"
|
|
/>
|
|
<template v-else>
|
|
<input
|
|
v-if="inputProps.mask"
|
|
ref="editField"
|
|
v-model="editingContent"
|
|
v-mask="inputProps.mask"
|
|
:type="inputProps.type"
|
|
autofocus
|
|
class="editable-field form-input input-sm px-1"
|
|
@blur="editOFF"
|
|
>
|
|
<BaseSelect
|
|
v-else-if="inputProps.type === 'boolean'"
|
|
v-model="editingContent"
|
|
:options="['true', 'false']"
|
|
class="form-select small-select editable-field"
|
|
@blur="editOFF"
|
|
/>
|
|
<BaseSelect
|
|
v-else-if="enumArray"
|
|
v-model="editingContent"
|
|
:options="enumArray"
|
|
class="form-select small-select editable-field"
|
|
dropdown-class="small-select"
|
|
@blur="editOFF"
|
|
/>
|
|
<input
|
|
v-else
|
|
ref="editField"
|
|
v-model="editingContent"
|
|
:type="inputProps.type"
|
|
autofocus
|
|
class="editable-field form-input input-sm px-1"
|
|
@blur="editOFF"
|
|
>
|
|
</template>
|
|
</template>
|
|
</div>
|
|
<ConfirmModal
|
|
v-if="isTextareaEditor"
|
|
:confirm-text="t('word.update')"
|
|
size="medium"
|
|
@confirm="editOFF"
|
|
@hide="hideEditorModal"
|
|
>
|
|
<template #header>
|
|
<div class="d-flex">
|
|
<i class="mdi mdi-24px mdi-playlist-edit mr-1" /> <span class="cut-text">{{ t('word.edit') }} "{{ editingField }}"</span>
|
|
</div>
|
|
</template>
|
|
<template #body>
|
|
<div class="mb-2">
|
|
<div>
|
|
<TextEditor
|
|
v-model="editingContent"
|
|
editor-class="textarea-editor"
|
|
:mode="editorMode"
|
|
/>
|
|
</div>
|
|
<div class="editor-field-info p-vcentered">
|
|
<div class="d-flex p-vcentered">
|
|
<label for="editorMode" class="form-label mr-2">
|
|
<b>{{ t('word.content') }}</b>:
|
|
</label>
|
|
<BaseSelect
|
|
id="editorMode"
|
|
v-model="editorMode"
|
|
:options="availableLanguages"
|
|
option-label="name"
|
|
option-track-by="slug"
|
|
class="form-select select-sm"
|
|
/>
|
|
</div>
|
|
<div class="d-flex">
|
|
<div class="p-vcentered">
|
|
<div class="mr-4">
|
|
<b>{{ t('word.size') }}</b>: {{ editingContent ? editingContent.length : 0 }}
|
|
</div>
|
|
<div v-if="editingType">
|
|
<b>{{ t('word.type') }}</b>: {{ editingType.toUpperCase() }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</ConfirmModal>
|
|
<ConfirmModal
|
|
v-if="isMapModal"
|
|
:hide-footer="true"
|
|
size="medium"
|
|
@hide="hideEditorModal"
|
|
>
|
|
<template #header>
|
|
<div class="d-flex">
|
|
<i class="mdi mdi-24px mdi-map mr-1" /> <span class="cut-text">"{{ editingField }}"</span>
|
|
</div>
|
|
</template>
|
|
<template #body>
|
|
<BaseMap :points="editingContent" :is-multi-spatial="isMultiSpatial" />
|
|
</template>
|
|
</ConfirmModal>
|
|
<ConfirmModal
|
|
v-if="isBlobEditor"
|
|
:confirm-text="t('word.update')"
|
|
@confirm="editOFF"
|
|
@hide="hideEditorModal"
|
|
>
|
|
<template #header>
|
|
<div class="d-flex">
|
|
<i class="mdi mdi-24px mdi-playlist-edit mr-1" />
|
|
<span class="cut-text">{{ t('word.edit') }} "{{ editingField }}"</span>
|
|
</div>
|
|
</template>
|
|
<template #body>
|
|
<div class="mb-2">
|
|
<Transition name="jump-down">
|
|
<div v-if="contentInfo.size">
|
|
<img
|
|
v-if="isImage"
|
|
:src="`data:${contentInfo.mime};base64, ${bufferToBase64(editingContent)}`"
|
|
class="img-responsive p-centered bg-checkered"
|
|
>
|
|
<div v-else class="text-center">
|
|
<i class="mdi mdi-36px mdi-file" />
|
|
</div>
|
|
<div class="editor-buttons mt-2">
|
|
<button class="btn btn-link btn-sm" @click="downloadFile">
|
|
<span>{{ t('word.download') }}</span>
|
|
<i class="mdi mdi-24px mdi-download ml-1" />
|
|
</button>
|
|
<button class="btn btn-link btn-sm" @click="prepareToDelete">
|
|
<span>{{ t('word.delete') }}</span>
|
|
<i class="mdi mdi-24px mdi-delete-forever ml-1" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
<div class="editor-field-info">
|
|
<div>
|
|
<b>{{ t('word.size') }}</b>: {{ formatBytes(editingContent.length) }}<br>
|
|
<b>{{ t('word.mimeType') }}</b>: {{ contentInfo.mime }}
|
|
</div>
|
|
<div v-if="editingType">
|
|
<b>{{ t('word.type') }}</b>: {{ editingType.toUpperCase() }}
|
|
</div>
|
|
</div>
|
|
<div class="mt-3">
|
|
<label>{{ t('message.uploadFile') }}</label>
|
|
<input
|
|
class="form-input"
|
|
type="file"
|
|
@change="filesChange($event as any)"
|
|
>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</ConfirmModal>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, Prop, ref, Ref, watch, nextTick } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import * as moment from 'moment';
|
|
import { ModelOperations } from '@vscode/vscode-languagedetection';
|
|
import { mimeFromHex } from 'common/libs/mimeFromHex';
|
|
import { formatBytes } from 'common/libs/formatBytes';
|
|
import { bufferToBase64 } from 'common/libs/bufferToBase64';
|
|
import hexToBinary, { HexChar } from 'common/libs/hexToBinary';
|
|
import {
|
|
TEXT,
|
|
LONG_TEXT,
|
|
ARRAY,
|
|
TEXT_SEARCH,
|
|
NUMBER,
|
|
FLOAT,
|
|
BOOLEAN,
|
|
DATE,
|
|
TIME,
|
|
DATETIME,
|
|
BLOB,
|
|
BIT,
|
|
HAS_TIMEZONE,
|
|
SPATIAL,
|
|
IS_MULTI_SPATIAL
|
|
} from 'common/fieldTypes';
|
|
import ConfirmModal from '@/components/BaseConfirmModal.vue';
|
|
import TextEditor from '@/components/BaseTextEditor.vue';
|
|
import BaseMap from '@/components/BaseMap.vue';
|
|
import ForeignKeySelect from '@/components/ForeignKeySelect.vue';
|
|
import BaseSelect from '@/components/BaseSelect.vue';
|
|
import { QueryForeign, TableField } from 'common/interfaces/antares';
|
|
|
|
const { t } = useI18n();
|
|
|
|
const props = defineProps({
|
|
row: Object,
|
|
fields: Object as Prop<{
|
|
[key: string]: TableField;
|
|
}>,
|
|
keyUsage: Array as Prop<QueryForeign[]>,
|
|
itemHeight: Number,
|
|
elementType: { type: String, default: 'table' },
|
|
selected: { type: Boolean, default: false },
|
|
selectedCell: { type: String, default: null }
|
|
});
|
|
|
|
const emit = defineEmits(['update-field', 'select-row', 'contextmenu', 'start-editing', 'stop-editing']);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const isInlineEditor: Ref<any> = ref({});
|
|
const isTextareaEditor = ref(false);
|
|
const isBlobEditor = ref(false);
|
|
const isMapModal = ref(false);
|
|
const isMultiSpatial = ref(false);
|
|
const willBeDeleted = ref(false);
|
|
const originalContent = ref(null);
|
|
const editingContent = ref(null);
|
|
const editingType = ref(null);
|
|
const editingField = ref(null);
|
|
const editingLength = ref(null);
|
|
const editorMode = ref('text');
|
|
const contentInfo = ref({
|
|
ext: '',
|
|
mime: '',
|
|
size: null
|
|
});
|
|
const fileToUpload = ref(null);
|
|
const availableLanguages = ref([
|
|
{ name: 'TEXT', slug: 'text', id: 'text' },
|
|
{ name: 'HTML', slug: 'html', id: 'html' },
|
|
{ name: 'XML', slug: 'xml', id: 'xml' },
|
|
{ name: 'JSON', slug: 'json', id: 'json' },
|
|
{ name: 'SVG', slug: 'svg', id: 'svg' },
|
|
{ name: 'INI', slug: 'ini', id: 'ini' },
|
|
{ name: 'MARKDOWN', slug: 'markdown', id: 'md' },
|
|
{ name: 'YAML', slug: 'yaml', id: 'yaml' }
|
|
]);
|
|
|
|
const inputProps = computed(() => {
|
|
if ([...TEXT, ...LONG_TEXT].includes(editingType.value))
|
|
return { type: 'text', mask: false };
|
|
|
|
if ([...NUMBER, ...FLOAT].includes(editingType.value))
|
|
return { type: 'number', mask: false };
|
|
|
|
if (TIME.includes(editingType.value)) {
|
|
let timeMask = '##:##:##';
|
|
const precision = props.fields[editingField.value].length;
|
|
|
|
for (let i = 0; i < precision; i++)
|
|
timeMask += i === 0 ? '.#' : '#';
|
|
|
|
if (HAS_TIMEZONE.includes(editingType.value))
|
|
timeMask += 'X##';
|
|
|
|
return { type: 'text', mask: timeMask };
|
|
}
|
|
|
|
if (DATE.includes(editingType.value))
|
|
return { type: 'text', mask: '####-##-##' };
|
|
|
|
if (DATETIME.includes(editingType.value)) {
|
|
let datetimeMask = '####-##-## ##:##:##';
|
|
const precision = props.fields[editingField.value].length;
|
|
|
|
for (let i = 0; i < precision; i++)
|
|
datetimeMask += i === 0 ? '.#' : '#';
|
|
|
|
if (HAS_TIMEZONE.includes(editingType.value))
|
|
datetimeMask += 'X##';
|
|
|
|
return { type: 'text', mask: datetimeMask };
|
|
}
|
|
|
|
if (BLOB.includes(editingType.value))
|
|
return { type: 'file', mask: false };
|
|
|
|
if (BOOLEAN.includes(editingType.value))
|
|
return { type: 'boolean', mask: false };
|
|
|
|
if (SPATIAL.includes(editingType.value))
|
|
return { type: 'map', mask: false };
|
|
|
|
return { type: 'text', mask: false };
|
|
});
|
|
|
|
const isImage = computed(() => {
|
|
return ['gif', 'jpg', 'png', 'bmp', 'ico', 'tif'].includes(contentInfo.value.ext);
|
|
});
|
|
|
|
const foreignKeys = computed(() => {
|
|
return props.keyUsage.map(key => key.field);
|
|
});
|
|
|
|
const isEditable = computed(() => {
|
|
if (props.elementType === 'view') return false;
|
|
|
|
if (props.fields) {
|
|
const nElements = Object.keys(props.fields).reduce((acc, curr) => {
|
|
acc.add(props.fields[curr].table);
|
|
acc.add(props.fields[curr].schema);
|
|
return acc;
|
|
}, new Set([]));
|
|
|
|
if (nElements.size > 2) return false;
|
|
|
|
return !!(props.fields[Object.keys(props.fields)[0]].schema && props.fields[Object.keys(props.fields)[0]].table);
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
const isBaseSelectField = computed(() => {
|
|
return isForeignKey(editingField.value) || inputProps.value.type === 'boolean' || enumArray.value;
|
|
});
|
|
|
|
const enumArray = computed(() => {
|
|
if (props.fields[editingField.value] && props.fields[editingField.value].enumValues)
|
|
return props.fields[editingField.value].enumValues.replaceAll('\'', '').split(',');
|
|
return false;
|
|
});
|
|
|
|
const isForeignKey = (key: string) => {
|
|
if (key) {
|
|
if (key.includes('.'))
|
|
key = key.split('.').pop();
|
|
|
|
return foreignKeys.value.includes(key);
|
|
}
|
|
};
|
|
|
|
const isNull = (value: null | string | number) => {
|
|
return value === null ? ' is-null' : '';
|
|
};
|
|
|
|
const typeClass = (type: string) => {
|
|
if (type)
|
|
return `type-${type.toLowerCase().replaceAll(' ', '_').replaceAll('"', '')}`;
|
|
return '';
|
|
};
|
|
|
|
const editON = async (field: string) => {
|
|
if (!isEditable.value || editingType.value === 'none') return;
|
|
|
|
const content = props.row[field];
|
|
const type = props.fields[field].type.toUpperCase();
|
|
originalContent.value = typeFormat(content, type, props.fields[field].length);
|
|
editingType.value = type;
|
|
editingField.value = field;
|
|
editingLength.value = props.fields[field].length;
|
|
|
|
if ([...LONG_TEXT, ...ARRAY, ...TEXT_SEARCH].includes(type)) {
|
|
isTextareaEditor.value = true;
|
|
editingContent.value = typeFormat(content, type);
|
|
emit('start-editing', field);
|
|
return;
|
|
}
|
|
|
|
if (SPATIAL.includes(type)) {
|
|
if (content) {
|
|
isMultiSpatial.value = IS_MULTI_SPATIAL.includes(type);
|
|
isMapModal.value = true;
|
|
editingContent.value = typeFormat(content, type);
|
|
}
|
|
emit('start-editing', field);
|
|
return;
|
|
}
|
|
|
|
if (BLOB.includes(type)) {
|
|
isBlobEditor.value = true;
|
|
editingContent.value = content || '';
|
|
fileToUpload.value = null;
|
|
willBeDeleted.value = false;
|
|
|
|
if (content !== null) {
|
|
const buff = Buffer.from(editingContent.value);
|
|
if (buff.length) {
|
|
const hex = buff.toString('hex').substring(0, 8).toUpperCase();
|
|
const { ext, mime } = mimeFromHex(hex);
|
|
contentInfo.value = {
|
|
ext,
|
|
mime,
|
|
size: editingContent.value.length
|
|
};
|
|
}
|
|
}
|
|
emit('start-editing', field);
|
|
return;
|
|
}
|
|
|
|
// Inline editable fields
|
|
editingContent.value = originalContent.value;
|
|
|
|
const obj = { [field]: true };
|
|
isInlineEditor.value = { ...isInlineEditor.value, ...obj };
|
|
nextTick(() => {
|
|
document.querySelector<HTMLInputElement>('.editable-field').focus();
|
|
});
|
|
|
|
emit('start-editing', field);
|
|
};
|
|
|
|
const editOFF = () => {
|
|
if (!editingField.value) return;
|
|
|
|
isInlineEditor.value[editingField.value] = false;
|
|
let content;
|
|
if (!BLOB.includes(editingType.value)) {
|
|
if ([...DATETIME, ...TIME].includes(editingType.value)) {
|
|
if (editingContent.value.substring(editingContent.value.length - 1) === '.')
|
|
editingContent.value = editingContent.value.slice(0, -1);
|
|
}
|
|
|
|
// If not changed
|
|
if (editingContent.value === typeFormat(originalContent.value, editingType.value, editingLength.value)) {
|
|
editingType.value = null;
|
|
editingField.value = null;
|
|
emit('stop-editing', editingField.value);
|
|
return;
|
|
}
|
|
|
|
content = editingContent.value;
|
|
}
|
|
else { // Handle file upload
|
|
if (willBeDeleted.value) {
|
|
content = '';
|
|
willBeDeleted.value = false;
|
|
}
|
|
else {
|
|
if (!fileToUpload.value) return;
|
|
content = fileToUpload.value.file.path;
|
|
}
|
|
}
|
|
|
|
emit('update-field', {
|
|
field: props.fields[editingField.value].name,
|
|
type: editingType.value,
|
|
content
|
|
});
|
|
|
|
emit('stop-editing', editingField.value);
|
|
|
|
editingType.value = null;
|
|
editingField.value = null;
|
|
};
|
|
|
|
const hideEditorModal = () => {
|
|
isTextareaEditor.value = false;
|
|
isBlobEditor.value = false;
|
|
isMapModal.value = false;
|
|
isMultiSpatial.value = false;
|
|
emit('stop-editing', editingField.value);
|
|
};
|
|
|
|
const downloadFile = () => {
|
|
const downloadLink = document.createElement('a');
|
|
|
|
downloadLink.href = `data:${contentInfo.value.mime};base64, ${bufferToBase64(editingContent.value)}`;
|
|
downloadLink.setAttribute('download', `${editingField.value}.${contentInfo.value.ext}`);
|
|
document.body.appendChild(downloadLink);
|
|
|
|
downloadLink.click();
|
|
downloadLink.remove();
|
|
};
|
|
|
|
const filesChange = (event: Event & {target: {files: {name: string}[]}}) => {
|
|
const { files } = event.target;
|
|
if (!files.length) return;
|
|
|
|
fileToUpload.value = { name: files[0].name, file: files[0] };
|
|
willBeDeleted.value = false;
|
|
};
|
|
|
|
const prepareToDelete = () => {
|
|
editingContent.value = '';
|
|
contentInfo.value = {
|
|
ext: '',
|
|
mime: '',
|
|
size: null
|
|
};
|
|
willBeDeleted.value = true;
|
|
};
|
|
|
|
const selectRow = (event: Event, field: string) => {
|
|
emit('select-row', event, props.row, field);
|
|
};
|
|
|
|
const getKeyUsage = (keyName: string) => {
|
|
if (keyName.includes('.'))
|
|
return props.keyUsage.find(key => key.field === keyName.split('.').pop());
|
|
return props.keyUsage.find(key => key.field === keyName);
|
|
};
|
|
|
|
const openContext = (event: MouseEvent, payload: { id: string; field?: string; orgField: string; isEditable?: boolean }) => {
|
|
payload.field = props.fields[payload.orgField].name;// Ensures field name only
|
|
payload.isEditable = isEditable.value;
|
|
emit('contextmenu', event, payload);
|
|
};
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
e.stopPropagation();
|
|
|
|
if (!editingField.value && e.key === 'Enter')
|
|
return editON(props.selectedCell);
|
|
|
|
if (editingField.value && e.key === 'Enter' && !isBaseSelectField.value)
|
|
return editOFF();
|
|
|
|
if (editingField.value && e.key === 'Escape') {
|
|
isInlineEditor.value[editingField.value] = false;
|
|
editingField.value = null;
|
|
emit('stop-editing', editingField.value);
|
|
}
|
|
};
|
|
|
|
const cutText = (val: string) => {
|
|
if (typeof val !== 'string') return val;
|
|
return val.length > 128 ? `${val.substring(0, 128)}[...]` : val;
|
|
};
|
|
|
|
const typeFormat = (val: string | number | Date | number[], type: string, precision?: number | false) => {
|
|
if (!val) return val;
|
|
|
|
type = type.toUpperCase();
|
|
|
|
if (DATE.includes(type))
|
|
return moment(val).isValid() ? moment(val).format('YYYY-MM-DD') : val;
|
|
|
|
if (DATETIME.includes(type)) {
|
|
if (typeof val === 'string')
|
|
return val;
|
|
|
|
let datePrecision = '';
|
|
for (let i = 0; i < precision; i++)
|
|
datePrecision += i === 0 ? '.S' : 'S';
|
|
|
|
return moment(val).isValid() ? moment(val).format(`YYYY-MM-DD HH:mm:ss${datePrecision}`) : val;
|
|
}
|
|
|
|
if (BLOB.includes(type)) {
|
|
const buff = Buffer.from(val as string);
|
|
if (!buff.length) return '';
|
|
|
|
const hex = buff.toString('hex').substring(0, 8).toUpperCase();
|
|
return `${mimeFromHex(hex).mime} (${formatBytes(buff.length)})`;
|
|
}
|
|
|
|
if (BIT.includes(type)) {
|
|
if (typeof val === 'number') val = [val] as number[];
|
|
const hex = Buffer.from(val as number[]).toString('hex') as unknown as HexChar[];
|
|
const bitString = hexToBinary(hex);
|
|
return parseInt(bitString).toString().padStart(Number(precision), '0');
|
|
}
|
|
|
|
if (ARRAY.includes(type)) {
|
|
if (Array.isArray(val))
|
|
return JSON.stringify(val).replaceAll('[', '{').replaceAll(']', '}');
|
|
return val;
|
|
}
|
|
|
|
if (SPATIAL.includes(type))
|
|
return val;
|
|
|
|
return typeof val === 'object' ? JSON.stringify(val) : val;
|
|
};
|
|
|
|
watch(() => props.fields, () => {
|
|
Object.keys(props.fields).forEach(field => {
|
|
isInlineEditor.value[field] = false;
|
|
});
|
|
});
|
|
|
|
watch(isTextareaEditor, (val) => {
|
|
if (val) {
|
|
const modelOperations = new ModelOperations();
|
|
(async () => {
|
|
const detected = await modelOperations.runModel(editingContent.value);
|
|
const filteredLanguages = detected.filter(dLang =>
|
|
availableLanguages.value.some(aLang => aLang.id === dLang.languageId) &&
|
|
dLang.confidence > 0.1
|
|
);
|
|
|
|
if (filteredLanguages.length)
|
|
editorMode.value = availableLanguages.value.find(lang => lang.id === filteredLanguages[0].languageId).slug;
|
|
})();
|
|
}
|
|
});
|
|
|
|
watch(() => props.selected, (isSelected) => {
|
|
if (isSelected)
|
|
window.addEventListener('keydown', onKey);
|
|
|
|
else {
|
|
editOFF();
|
|
window.removeEventListener('keydown', onKey);
|
|
}
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
if (props.selected)
|
|
window.removeEventListener('keydown', onKey);
|
|
});
|
|
</script>
|
|
|
|
<style lang="scss">
|
|
.editable-field {
|
|
margin: 0;
|
|
border: none;
|
|
line-height: 1;
|
|
width: 100%;
|
|
max-height: 21px;
|
|
border-radius: 3px;
|
|
font-size: 0.7rem;
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
}
|
|
|
|
.cell-content {
|
|
display: block;
|
|
padding: 0 0.2rem;
|
|
min-height: 0.8rem;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.textarea-editor {
|
|
height: 50vh !important;
|
|
}
|
|
|
|
.editor-field-info {
|
|
margin-top: 0.4rem;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
white-space: normal;
|
|
}
|
|
|
|
.editor-buttons {
|
|
display: flex;
|
|
justify-content: space-evenly;
|
|
|
|
.btn {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
}
|
|
</style>
|