Split import page into separate local and remote database workflows.
Route IndexedDB and server import/export through dedicated APIs and UI panels so each storage target can be managed independently of the active data mode. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,6 +5,9 @@ import { generateId } from '../../lib/uuid'
|
|||||||
import { createDefaultMapTypeRecord } from '../../domain/mapTypeDefaults'
|
import { createDefaultMapTypeRecord } from '../../domain/mapTypeDefaults'
|
||||||
import { parseVcf } from '../../lib/import/vcard'
|
import { parseVcf } from '../../lib/import/vcard'
|
||||||
import { serializeContactsExport } from '../../lib/export/contacts'
|
import { serializeContactsExport } from '../../lib/export/contacts'
|
||||||
|
import api from '../../api'
|
||||||
|
import { localContactRepository } from '../../infrastructure/repositories/contactRepository.local'
|
||||||
|
import { remoteContactRepository } from '../../infrastructure/repositories/contactRepository.remote'
|
||||||
|
|
||||||
function isLikelyEmail(value) {
|
function isLikelyEmail(value) {
|
||||||
return value.includes('@') && value.includes('.')
|
return value.includes('@') && value.includes('.')
|
||||||
@@ -109,8 +112,107 @@ export async function importContactsFromFile(file) {
|
|||||||
return { total: rows.length, created, skipped, errors }
|
return { total: rows.length, created, skipped, errors }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function importRowsWithCreator(rows, createFn) {
|
||||||
|
let created = 0
|
||||||
|
let skipped = 0
|
||||||
|
const errors = []
|
||||||
|
for (let i = 0; i < rows.length; i += 1) {
|
||||||
|
const payload = toContactPayload(rows[i])
|
||||||
|
if (!payload.name) {
|
||||||
|
skipped += 1
|
||||||
|
errors.push(`Запись ${i + 1}: отсутствует имя контакта`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await createFn(payload)
|
||||||
|
created += 1
|
||||||
|
}
|
||||||
|
return { total: rows.length, created, skipped, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseContactRowsFromFile(file, { allowLocalDump = false } = {}) {
|
||||||
|
if (!file) throw new Error('Файл не выбран')
|
||||||
|
const name = file.name.toLowerCase()
|
||||||
|
const rawText = await readText(file)
|
||||||
|
|
||||||
|
if (name.endsWith('.csv')) {
|
||||||
|
return { kind: 'rows', rows: parseCsv(rawText) }
|
||||||
|
}
|
||||||
|
if (name.endsWith('.json')) {
|
||||||
|
const raw = JSON.parse(rawText)
|
||||||
|
if (isLocalDataDump(raw) || isEncryptedLocalDump(raw)) {
|
||||||
|
if (!allowLocalDump) {
|
||||||
|
throw new Error('Полный бэкап приложения импортируйте в разделе «Локальная база».')
|
||||||
|
}
|
||||||
|
return { kind: 'dump', file }
|
||||||
|
}
|
||||||
|
return { kind: 'rows', rows: normalizeRows(raw) }
|
||||||
|
}
|
||||||
|
if (name.endsWith('.vcf') || name.endsWith('.vcard')) {
|
||||||
|
const rows = parseVcf(rawText)
|
||||||
|
if (!rows.length) {
|
||||||
|
throw new Error('В файле vCard не найдено контактов.')
|
||||||
|
}
|
||||||
|
return { kind: 'rows', rows }
|
||||||
|
}
|
||||||
|
throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf) файлы.')
|
||||||
|
}
|
||||||
|
|
||||||
const ALLOWED_EXPORT_FORMATS = new Set(['csv', 'json', 'vcf'])
|
const ALLOWED_EXPORT_FORMATS = new Set(['csv', 'json', 'vcf'])
|
||||||
|
|
||||||
|
function contactsExportResult(contacts, format) {
|
||||||
|
const normalized = String(format || 'csv').toLowerCase()
|
||||||
|
if (!ALLOWED_EXPORT_FORMATS.has(normalized)) {
|
||||||
|
throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf).')
|
||||||
|
}
|
||||||
|
const { filename, mime, content } = serializeContactsExport(contacts, normalized)
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
blob: new Blob([content], { type: mime }),
|
||||||
|
count: contacts.length,
|
||||||
|
format: normalized,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importContactsToLocalFromFile(file) {
|
||||||
|
const parsed = await parseContactRowsFromFile(file, { allowLocalDump: true })
|
||||||
|
if (parsed.kind === 'dump') {
|
||||||
|
const summary = await importLocalDump(parsed.file, '')
|
||||||
|
return {
|
||||||
|
total: summary.importedContacts + summary.importedRelations,
|
||||||
|
created: summary.importedContacts,
|
||||||
|
importedRelations: summary.importedRelations,
|
||||||
|
skipped: 0,
|
||||||
|
errors: [],
|
||||||
|
isDump: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return importRowsWithCreator(parsed.rows, (payload) => localContactRepository.create(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importContactsToRemoteFromFile(file) {
|
||||||
|
await parseContactRowsFromFile(file, { allowLocalDump: false })
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
const { data } = await api.post('/v1/import/', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 120000,
|
||||||
|
})
|
||||||
|
if (data?.error) throw new Error(data.error)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportContactsFromLocal({ format = 'csv' } = {}) {
|
||||||
|
const contacts = await localContactRepository.list()
|
||||||
|
if (!contacts.length) throw new Error('Нет контактов в локальной базе.')
|
||||||
|
return contactsExportResult(contacts, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportContactsFromRemote({ format = 'csv' } = {}) {
|
||||||
|
const contacts = await remoteContactRepository.list()
|
||||||
|
if (!contacts.length) throw new Error('Нет контактов на сервере.')
|
||||||
|
return contactsExportResult(contacts, format)
|
||||||
|
}
|
||||||
|
|
||||||
export async function exportContacts({ format = 'csv' } = {}) {
|
export async function exportContacts({ format = 'csv' } = {}) {
|
||||||
const normalized = String(format || 'csv').toLowerCase()
|
const normalized = String(format || 'csv').toLowerCase()
|
||||||
if (!ALLOWED_EXPORT_FORMATS.has(normalized)) {
|
if (!ALLOWED_EXPORT_FORMATS.has(normalized)) {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<div class="export-row">
|
||||||
|
<div class="form-group export-format-field">
|
||||||
|
<label :for="inputId">Формат файла</label>
|
||||||
|
<select :id="inputId" :value="modelValue" class="form-control" @change="onChange">
|
||||||
|
<option value="csv">CSV (.csv)</option>
|
||||||
|
<option value="json">JSON (.json)</option>
|
||||||
|
<option value="vcf">vCard (.vcf)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled || exporting"
|
||||||
|
@click="$emit('export')"
|
||||||
|
>
|
||||||
|
{{ exporting ? 'Экспорт...' : buttonLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="result" class="alert" :class="result.error ? 'alert-error' : 'alert-success'" style="margin-top:12px;">
|
||||||
|
<span v-if="result.error">{{ result.error }}</span>
|
||||||
|
<span v-else>
|
||||||
|
Экспортировано контактов: <strong>{{ result.count }}</strong>
|
||||||
|
({{ result.formatLabel }}).
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useId } from 'vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
modelValue: { type: String, default: 'csv' },
|
||||||
|
exporting: { type: Boolean, default: false },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
result: { type: Object, default: null },
|
||||||
|
buttonLabel: { type: String, default: 'Экспортировать' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'export'])
|
||||||
|
const inputId = useId()
|
||||||
|
|
||||||
|
function onChange(event) {
|
||||||
|
emit('update:modelValue', event.target.value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.export-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.export-format-field {
|
||||||
|
margin-bottom: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<template>
|
||||||
|
<div class="import-file-block" :class="{ 'import-file-block--disabled': disabled }">
|
||||||
|
<h4 class="subsection-title">Импорт файла</h4>
|
||||||
|
<p class="text-muted import-hint">
|
||||||
|
CSV, JSON или vCard (.vcf).
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="drop-zone"
|
||||||
|
:class="{ 'drag-over': isDragging, 'drop-zone--disabled': disabled }"
|
||||||
|
@dragover.prevent="onDragOver"
|
||||||
|
@dragleave="isDragging = false"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
|
@click="openPicker"
|
||||||
|
>
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="drop-zone__icon">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="17 8 12 3 7 8"/>
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
<div class="drop-zone__label">
|
||||||
|
{{ file?.name || 'Перетащите файл или нажмите для выбора' }}
|
||||||
|
</div>
|
||||||
|
<input ref="fileInput" type="file" accept=".csv,.json,.vcf,.vcard" style="display:none" @change="onFileSelect" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImportResultAlert :result="result" />
|
||||||
|
|
||||||
|
<div class="import-file-actions">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled || !file || importing"
|
||||||
|
@click="$emit('import')"
|
||||||
|
>
|
||||||
|
<svg v-if="importing" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spin-icon">
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.22-8.56"/>
|
||||||
|
</svg>
|
||||||
|
{{ importing ? 'Импорт...' : importLabel }}
|
||||||
|
</button>
|
||||||
|
<button v-if="file" class="btn btn-secondary" type="button" :disabled="importing" @click="clear">
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import ImportResultAlert from './ImportResultAlert.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
file: { type: Object, default: null },
|
||||||
|
importing: { type: Boolean, default: false },
|
||||||
|
result: { type: Object, default: null },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
importLabel: { type: String, default: 'Импортировать' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:file', 'import', 'reset'])
|
||||||
|
|
||||||
|
const fileInput = ref(null)
|
||||||
|
const isDragging = ref(false)
|
||||||
|
|
||||||
|
function openPicker() {
|
||||||
|
if (props.disabled) return
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver() {
|
||||||
|
if (!props.disabled) isDragging.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileSelect(e) {
|
||||||
|
emit('update:file', e.target.files[0] || null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e) {
|
||||||
|
isDragging.value = false
|
||||||
|
if (props.disabled) return
|
||||||
|
const picked = e.dataTransfer.files[0]
|
||||||
|
if (picked) {
|
||||||
|
emit('update:file', picked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
emit('update:file', null)
|
||||||
|
emit('reset')
|
||||||
|
if (fileInput.value) fileInput.value.value = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.subsection-title {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.import-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 28px 16px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.drop-zone:hover,
|
||||||
|
.drag-over {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-dim);
|
||||||
|
}
|
||||||
|
.drop-zone--disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.drop-zone__icon {
|
||||||
|
opacity: 0.4;
|
||||||
|
margin: 0 auto 10px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.drop-zone__label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.import-file-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.spin-icon {
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
vertical-align: -2px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="result" class="alert" :class="result.error ? 'alert-error' : 'alert-success'" style="margin-top:16px;">
|
||||||
|
<span v-if="result.error">{{ result.error }}</span>
|
||||||
|
<span v-else>
|
||||||
|
В файле: <strong>{{ result.total ?? result.created + result.skipped }}</strong>,
|
||||||
|
импортировано: <strong>{{ result.created }}</strong> контактов<template v-if="result.importedRelations">, <strong>{{ result.importedRelations }}</strong> связей</template>,
|
||||||
|
пропущено: {{ result.skipped }}.
|
||||||
|
<span v-if="result.errors?.length"> Ошибок: {{ result.errors.length }}.</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="result?.errors?.length" style="margin-top:8px;">
|
||||||
|
<div v-for="e in result.errors" :key="e" class="text-muted" style="font-size:12px;">{{ e }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
result: { type: Object, default: null },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -19,10 +19,15 @@ import {
|
|||||||
} from '../infrastructure/repositories/repositoryFactory'
|
} from '../infrastructure/repositories/repositoryFactory'
|
||||||
import {
|
import {
|
||||||
importContactsFromFile,
|
importContactsFromFile,
|
||||||
|
importContactsToLocalFromFile,
|
||||||
|
importContactsToRemoteFromFile,
|
||||||
exportContacts as exportContactsUseCase,
|
exportContacts as exportContactsUseCase,
|
||||||
|
exportContactsFromLocal,
|
||||||
|
exportContactsFromRemote,
|
||||||
exportLocalData,
|
exportLocalData,
|
||||||
importLocalDump,
|
importLocalDump,
|
||||||
} from '../application/usecases/importExport'
|
} from '../application/usecases/importExport'
|
||||||
|
import { isLocalMode, isRemoteMode } from '../infrastructure/config/dataMode'
|
||||||
import { syncPendingChanges } from '../application/usecases/sync'
|
import { syncPendingChanges } from '../application/usecases/sync'
|
||||||
|
|
||||||
export const useContactsStore = defineStore('contacts', {
|
export const useContactsStore = defineStore('contacts', {
|
||||||
@@ -211,10 +216,42 @@ export const useContactsStore = defineStore('contacts', {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async importContactsToLocal(file) {
|
||||||
|
return this.withLoading('contactsLoading', async () => {
|
||||||
|
const data = await importContactsToLocalFromFile(file)
|
||||||
|
if (isLocalMode()) {
|
||||||
|
await this.fetchContacts()
|
||||||
|
if (data.isDump) await this.fetchRelations()
|
||||||
|
}
|
||||||
|
if (isLocalMode()) await syncPendingChanges()
|
||||||
|
this.bumpDataRevision()
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async importContactsToRemote(file) {
|
||||||
|
return this.withLoading('contactsLoading', async () => {
|
||||||
|
const data = await importContactsToRemoteFromFile(file)
|
||||||
|
if (isRemoteMode()) {
|
||||||
|
await this.fetchContacts()
|
||||||
|
}
|
||||||
|
this.bumpDataRevision()
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
async exportContacts(format = 'csv') {
|
async exportContacts(format = 'csv') {
|
||||||
return exportContactsUseCase({ format })
|
return exportContactsUseCase({ format })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async exportLocalContacts(format = 'csv') {
|
||||||
|
return exportContactsFromLocal({ format })
|
||||||
|
},
|
||||||
|
|
||||||
|
async exportRemoteContacts(format = 'csv') {
|
||||||
|
return exportContactsFromRemote({ format })
|
||||||
|
},
|
||||||
|
|
||||||
async exportData(passphrase = '') {
|
async exportData(passphrase = '') {
|
||||||
return exportLocalData({ passphrase })
|
return exportLocalData({ passphrase })
|
||||||
},
|
},
|
||||||
|
|||||||
+288
-171
@@ -3,126 +3,70 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>Импорт и экспорт</h2>
|
<h2>Импорт и экспорт</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-content content-narrow">
|
<div class="page-content">
|
||||||
<div class="card">
|
<div class="card format-examples">
|
||||||
<h3 class="section-title">Загрузить файл</h3>
|
<h3 class="section-title">Поддерживаемые форматы</h3>
|
||||||
<p class="text-muted section-subtitle">
|
<p class="text-muted section-subtitle">
|
||||||
Поддерживаются форматы <strong style="color:var(--text)">CSV</strong>, <strong style="color:var(--text)">JSON</strong> и <strong style="color:var(--text)">vCard (.vcf)</strong>.
|
CSV, JSON и vCard (.vcf). Полный бэкап приложения (контакты, связи, карты) — только JSON в разделе локальной базы.
|
||||||
</p>
|
</p>
|
||||||
|
<pre class="format-pre">name,email,phone,organization,position,notes
|
||||||
<!-- Format examples -->
|
|
||||||
<div class="card" style="background:var(--surface-alt);margin-bottom:18px;padding:14px;">
|
|
||||||
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px;">Пример CSV:</div>
|
|
||||||
<pre style="font-size:12px;color:var(--green);overflow-x:auto;">name,email,phone,organization,position,notes
|
|
||||||
Иван Иванов,ivan@example.com,+7-900-000-0001,ООО Ромашка,Директор,</pre>
|
Иван Иванов,ivan@example.com,+7-900-000-0001,ООО Ромашка,Директор,</pre>
|
||||||
<div style="font-size:12px;color:var(--text-muted);margin:12px 0 8px;">Пример JSON:</div>
|
|
||||||
<pre style="font-size:12px;color:var(--green);overflow-x:auto;">[{"name":"Иван Иванов","email":"ivan@example.com","organization":"ООО Ромашка"}]</pre>
|
|
||||||
<div style="font-size:12px;color:var(--text-muted);margin:12px 0 8px;">Пример vCard (.vcf):</div>
|
|
||||||
<pre style="font-size:12px;color:var(--green);overflow-x:auto;">BEGIN:VCARD
|
|
||||||
FN:Иван Иванов
|
|
||||||
EMAIL:ivan@example.com
|
|
||||||
TEL:+79000000001
|
|
||||||
ORG:ООО Ромашка
|
|
||||||
END:VCARD</pre>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Drop zone -->
|
<div class="import-grid">
|
||||||
<div
|
<!-- Local -->
|
||||||
class="drop-zone"
|
<div class="card import-panel">
|
||||||
:class="{ 'drag-over': isDragging }"
|
<div class="import-panel__head">
|
||||||
@dragover.prevent="isDragging = true"
|
<h3 class="section-title">Локальная база</h3>
|
||||||
@dragleave="isDragging = false"
|
<span class="import-badge">IndexedDB</span>
|
||||||
@drop.prevent="onDrop"
|
|
||||||
@click="$refs.fileInput.click()"
|
|
||||||
>
|
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:.4;margin:0 auto 10px;display:block;">
|
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
|
||||||
<polyline points="17 8 12 3 7 8"/>
|
|
||||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
|
||||||
</svg>
|
|
||||||
<div style="font-size:13px;color:var(--text-muted);">
|
|
||||||
{{ selectedFile ? selectedFile.name : 'Перетащите файл или нажмите для выбора' }}
|
|
||||||
</div>
|
</div>
|
||||||
<input ref="fileInput" type="file" accept=".csv,.json,.vcf,.vcard" style="display:none" @change="onFileSelect" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="result" class="alert" :class="result.error ? 'alert-error' : 'alert-success'" style="margin-top:16px;">
|
|
||||||
<span v-if="result.error">{{ result.error }}</span>
|
|
||||||
<span v-else>
|
|
||||||
В файле: <strong>{{ result.total ?? result.created + result.skipped }}</strong>,
|
|
||||||
импортировано: <strong>{{ result.created }}</strong> контактов<template v-if="result.importedRelations">, <strong>{{ result.importedRelations }}</strong> связей</template>,
|
|
||||||
пропущено: {{ result.skipped }}.
|
|
||||||
<span v-if="result.errors?.length"> Ошибок: {{ result.errors.length }}.</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="result?.errors?.length" style="margin-top:8px;">
|
|
||||||
<div v-for="e in result.errors" :key="e" class="text-muted" style="font-size:12px;">{{ e }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-top:16px;">
|
|
||||||
<button
|
|
||||||
class="btn btn-primary"
|
|
||||||
:disabled="!selectedFile || importing"
|
|
||||||
@click="doImport"
|
|
||||||
>
|
|
||||||
<svg v-if="importing" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation:spin .7s linear infinite;">
|
|
||||||
<path d="M21 12a9 9 0 1 1-6.22-8.56"/>
|
|
||||||
</svg>
|
|
||||||
{{ importing ? 'Импорт...' : 'Импортировать' }}
|
|
||||||
</button>
|
|
||||||
<button v-if="selectedFile" class="btn btn-secondary" style="margin-left:8px;" @click="reset">Сбросить</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border);" />
|
|
||||||
<h3 class="section-title">Экспорт контактов</h3>
|
|
||||||
<p class="text-muted section-subtitle">
|
<p class="text-muted section-subtitle">
|
||||||
Скачать все контакты в выбранном формате: <strong style="color:var(--text)">CSV</strong>,
|
Данные в браузере. Не зависит от выбранного режима в настройках.
|
||||||
<strong style="color:var(--text)">JSON</strong> или <strong style="color:var(--text)">vCard (.vcf)</strong>
|
<span v-if="isLocalMode" class="import-mode-hint">Сейчас активен локальный режим.</span>
|
||||||
— совместимо с Nextcloud и другими адресными книгами.
|
|
||||||
</p>
|
</p>
|
||||||
<div class="export-row">
|
|
||||||
<div class="form-group" style="margin-bottom:0;flex:1;">
|
|
||||||
<label for="export-format">Формат файла</label>
|
|
||||||
<select id="export-format" v-model="exportFormat" class="form-control">
|
|
||||||
<option value="csv">CSV (.csv)</option>
|
|
||||||
<option value="json">JSON (.json)</option>
|
|
||||||
<option value="vcf">vCard (.vcf)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="btn btn-primary"
|
|
||||||
:disabled="exporting || store.totalContacts === 0"
|
|
||||||
@click="doExportContacts"
|
|
||||||
>
|
|
||||||
{{ exporting ? 'Экспорт...' : 'Экспортировать' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p v-if="store.totalContacts === 0" class="text-muted" style="font-size:12px;margin-top:8px;">
|
|
||||||
Нет контактов для экспорта.
|
|
||||||
</p>
|
|
||||||
<div v-if="exportResult" class="alert" :class="exportResult.error ? 'alert-error' : 'alert-success'" style="margin-top:12px;">
|
|
||||||
<span v-if="exportResult.error">{{ exportResult.error }}</span>
|
|
||||||
<span v-else>
|
|
||||||
Экспортировано контактов: <strong>{{ exportResult.count }}</strong>
|
|
||||||
({{ exportResult.formatLabel }}).
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border);" />
|
<ImportFileBlock
|
||||||
<h3 class="section-title">Бэкап локальной базы</h3>
|
v-model:file="localFile"
|
||||||
|
:importing="localImporting"
|
||||||
|
:result="localResult"
|
||||||
|
import-label="Импортировать в локальную БД"
|
||||||
|
@import="doLocalImport"
|
||||||
|
@reset="resetLocal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<hr class="import-divider" />
|
||||||
|
|
||||||
|
<h4 class="subsection-title">Экспорт контактов</h4>
|
||||||
|
<ExportFormatRow
|
||||||
|
v-model="localExportFormat"
|
||||||
|
:exporting="localExporting"
|
||||||
|
:disabled="localContactCount === 0"
|
||||||
|
:result="localExportResult"
|
||||||
|
button-label="Экспортировать локально"
|
||||||
|
@export="doLocalExport"
|
||||||
|
/>
|
||||||
|
<p v-if="localContactCount === 0" class="text-muted import-empty-hint">
|
||||||
|
В локальной базе нет контактов.
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-muted import-empty-hint">
|
||||||
|
Контактов в локальной базе: {{ localContactCount }}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr class="import-divider" />
|
||||||
|
|
||||||
|
<h4 class="subsection-title">Полный бэкап</h4>
|
||||||
<p class="text-muted section-subtitle">
|
<p class="text-muted section-subtitle">
|
||||||
Экспортирует/импортирует локальные данные. Пароль для шифрования необязателен.
|
Экспорт или восстановление всей локальной базы (контакты, связи, карты). Пароль необязателен.
|
||||||
</p>
|
</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Пароль шифрования (необязательно)</label>
|
<label>Пароль шифрования (необязательно)</label>
|
||||||
<input v-model="backupPassphrase" type="password" class="form-control" placeholder="Оставьте пустым для обычного JSON" />
|
<input v-model="backupPassphrase" type="password" class="form-control" placeholder="Оставьте пустым для обычного JSON" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="import-actions">
|
||||||
<button class="btn btn-secondary" :disabled="busyBackup" @click="doExport">
|
<button class="btn btn-secondary" type="button" :disabled="busyBackup" @click="doExportBackup">
|
||||||
{{ busyBackup ? 'Экспорт...' : 'Экспорт локальной БД' }}
|
{{ busyBackup ? 'Экспорт...' : 'Экспорт бэкапа' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary" style="margin-left:8px;" :disabled="busyBackup" @click="$refs.backupInput.click()">
|
<button class="btn btn-secondary" type="button" :disabled="busyBackup" @click="$refs.backupInput.click()">
|
||||||
Импорт бэкапа
|
Импорт бэкапа
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
@@ -134,27 +78,97 @@ END:VCARD</pre>
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Remote -->
|
||||||
|
<div class="card import-panel">
|
||||||
|
<div class="import-panel__head">
|
||||||
|
<h3 class="section-title">Удалённая база</h3>
|
||||||
|
<span class="import-badge import-badge--remote">Сервер</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted section-subtitle">
|
||||||
|
Django API. Нужен вход в аккаунт.
|
||||||
|
<span v-if="isRemoteMode" class="import-mode-hint">Сейчас активен серверный режим.</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="serverProbeLoading" class="text-muted server-status">Проверка сервера…</p>
|
||||||
|
<p v-else-if="serverProbe.ok" class="server-status server-status--ok">
|
||||||
|
Сервер доступен · контактов: {{ serverProbe.contactCount }} · связей: {{ serverProbe.relationCount }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="alert alert-error server-status">{{ serverProbe.message }}</p>
|
||||||
|
<p v-if="!isAuthenticated" class="alert alert-error server-status">
|
||||||
|
Войдите в аккаунт для работы с серверной базой.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ImportFileBlock
|
||||||
|
v-model:file="remoteFile"
|
||||||
|
:importing="remoteImporting"
|
||||||
|
:result="remoteResult"
|
||||||
|
:disabled="!canUseRemote"
|
||||||
|
import-label="Импортировать на сервер"
|
||||||
|
@import="doRemoteImport"
|
||||||
|
@reset="resetRemote"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<hr class="import-divider" />
|
||||||
|
|
||||||
|
<h4 class="subsection-title">Экспорт контактов</h4>
|
||||||
|
<ExportFormatRow
|
||||||
|
v-model="remoteExportFormat"
|
||||||
|
:exporting="remoteExporting"
|
||||||
|
:disabled="!canUseRemote || serverProbe.contactCount === 0"
|
||||||
|
:result="remoteExportResult"
|
||||||
|
button-label="Экспортировать с сервера"
|
||||||
|
@export="doRemoteExport"
|
||||||
|
/>
|
||||||
|
<p v-if="canUseRemote && serverProbe.contactCount === 0" class="text-muted import-empty-hint">
|
||||||
|
На сервере нет контактов.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text-muted migration-link">
|
||||||
|
Перенос между локальной и серверной базой — в
|
||||||
|
<RouterLink to="/settings">настройках</RouterLink>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
import { useContactsStore } from '../stores/contacts'
|
import { useContactsStore } from '../stores/contacts'
|
||||||
import { useNetworkMapsStore } from '../stores/networkMaps'
|
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||||
|
import { isLocalMode, isRemoteMode } from '../infrastructure/config/dataMode'
|
||||||
|
import { getStoredAccessToken } from '../stores/auth'
|
||||||
|
import { probeRemoteServer } from '../application/usecases/dataMigration'
|
||||||
|
import { localContactRepository } from '../infrastructure/repositories/contactRepository.local'
|
||||||
|
import ImportFileBlock from '../components/import/ImportFileBlock.vue'
|
||||||
|
import ExportFormatRow from '../components/import/ExportFormatRow.vue'
|
||||||
|
|
||||||
const store = useContactsStore()
|
const store = useContactsStore()
|
||||||
const mapsStore = useNetworkMapsStore()
|
const mapsStore = useNetworkMapsStore()
|
||||||
const fileInput = ref(null)
|
|
||||||
const selectedFile = ref(null)
|
const localFile = ref(null)
|
||||||
const isDragging = ref(false)
|
const remoteFile = ref(null)
|
||||||
const importing = ref(false)
|
const localImporting = ref(false)
|
||||||
const result = ref(null)
|
const remoteImporting = ref(false)
|
||||||
|
const localResult = ref(null)
|
||||||
|
const remoteResult = ref(null)
|
||||||
const backupPassphrase = ref('')
|
const backupPassphrase = ref('')
|
||||||
const busyBackup = ref(false)
|
const busyBackup = ref(false)
|
||||||
const exportFormat = ref('csv')
|
const localExportFormat = ref('csv')
|
||||||
const exporting = ref(false)
|
const remoteExportFormat = ref('csv')
|
||||||
const exportResult = ref(null)
|
const localExporting = ref(false)
|
||||||
|
const remoteExporting = ref(false)
|
||||||
|
const localExportResult = ref(null)
|
||||||
|
const remoteExportResult = ref(null)
|
||||||
|
const localContactCount = ref(0)
|
||||||
|
const serverProbeLoading = ref(true)
|
||||||
|
const serverProbe = ref({ ok: false, message: '', contactCount: 0, relationCount: 0 })
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => Boolean(getStoredAccessToken()))
|
||||||
|
const canUseRemote = computed(() => isAuthenticated.value && serverProbe.value.ok)
|
||||||
|
|
||||||
const exportFormatLabels = {
|
const exportFormatLabels = {
|
||||||
csv: 'CSV',
|
csv: 'CSV',
|
||||||
@@ -162,71 +176,119 @@ const exportFormatLabels = {
|
|||||||
vcf: 'vCard',
|
vcf: 'vCard',
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFileSelect(e) {
|
async function refreshLocalCount() {
|
||||||
selectedFile.value = e.target.files[0] || null
|
const contacts = await localContactRepository.list()
|
||||||
result.value = null
|
localContactCount.value = contacts.length
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDrop(e) {
|
async function refreshServerProbe() {
|
||||||
isDragging.value = false
|
serverProbeLoading.value = true
|
||||||
const file = e.dataTransfer.files[0]
|
serverProbe.value = await probeRemoteServer()
|
||||||
if (file) { selectedFile.value = file; result.value = null }
|
serverProbeLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doImport() {
|
onMounted(async () => {
|
||||||
if (!selectedFile.value) return
|
await Promise.all([refreshLocalCount(), refreshServerProbe()])
|
||||||
importing.value = true
|
})
|
||||||
result.value = null
|
|
||||||
|
watch(localFile, () => {
|
||||||
|
localResult.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(remoteFile, () => {
|
||||||
|
remoteResult.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
function resetLocal() {
|
||||||
|
localFile.value = null
|
||||||
|
localResult.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetRemote() {
|
||||||
|
remoteFile.value = null
|
||||||
|
remoteResult.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLocalImport() {
|
||||||
|
if (!localFile.value) return
|
||||||
|
localImporting.value = true
|
||||||
|
localResult.value = null
|
||||||
try {
|
try {
|
||||||
result.value = await store.importContacts(selectedFile.value)
|
localResult.value = await store.importContactsToLocal(localFile.value)
|
||||||
if (result.value.isDump) {
|
if (localResult.value.isDump) {
|
||||||
await mapsStore.fetchMaps()
|
await mapsStore.fetchMaps()
|
||||||
}
|
}
|
||||||
|
await refreshLocalCount()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
result.value = { error: e.message }
|
localResult.value = { error: e.message }
|
||||||
} finally {
|
} finally {
|
||||||
importing.value = false
|
localImporting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
async function doRemoteImport() {
|
||||||
selectedFile.value = null
|
if (!remoteFile.value || !canUseRemote.value) return
|
||||||
result.value = null
|
remoteImporting.value = true
|
||||||
if (fileInput.value) fileInput.value.value = ''
|
remoteResult.value = null
|
||||||
}
|
|
||||||
|
|
||||||
async function doExportContacts() {
|
|
||||||
exporting.value = true
|
|
||||||
exportResult.value = null
|
|
||||||
try {
|
try {
|
||||||
const { blob, filename, count, format } = await store.exportContacts(exportFormat.value)
|
remoteResult.value = await store.importContactsToRemote(remoteFile.value)
|
||||||
const url = URL.createObjectURL(blob)
|
await refreshServerProbe()
|
||||||
const link = document.createElement('a')
|
} catch (e) {
|
||||||
link.href = url
|
remoteResult.value = { error: e.message }
|
||||||
link.download = filename
|
} finally {
|
||||||
link.click()
|
remoteImporting.value = false
|
||||||
URL.revokeObjectURL(url)
|
}
|
||||||
exportResult.value = {
|
}
|
||||||
|
|
||||||
|
async function doLocalExport() {
|
||||||
|
localExporting.value = true
|
||||||
|
localExportResult.value = null
|
||||||
|
try {
|
||||||
|
const { blob, filename, count, format } = await store.exportLocalContacts(localExportFormat.value)
|
||||||
|
downloadBlob(blob, filename)
|
||||||
|
localExportResult.value = {
|
||||||
count,
|
count,
|
||||||
formatLabel: exportFormatLabels[format] || format,
|
formatLabel: exportFormatLabels[format] || format,
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
exportResult.value = { error: e?.message || 'Ошибка экспорта' }
|
localExportResult.value = { error: e?.message || 'Ошибка экспорта' }
|
||||||
} finally {
|
} finally {
|
||||||
exporting.value = false
|
localExporting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doExport() {
|
async function doRemoteExport() {
|
||||||
busyBackup.value = true
|
if (!canUseRemote.value) return
|
||||||
|
remoteExporting.value = true
|
||||||
|
remoteExportResult.value = null
|
||||||
try {
|
try {
|
||||||
const { blob, filename } = await store.exportData(backupPassphrase.value)
|
const { blob, filename, count, format } = await store.exportRemoteContacts(remoteExportFormat.value)
|
||||||
|
downloadBlob(blob, filename)
|
||||||
|
remoteExportResult.value = {
|
||||||
|
count,
|
||||||
|
formatLabel: exportFormatLabels[format] || format,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
remoteExportResult.value = { error: e?.message || 'Ошибка экспорта' }
|
||||||
|
} finally {
|
||||||
|
remoteExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBlob(blob, filename) {
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
link.download = filename
|
link.download = filename
|
||||||
link.click()
|
link.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doExportBackup() {
|
||||||
|
busyBackup.value = true
|
||||||
|
try {
|
||||||
|
const { blob, filename } = await store.exportData(backupPassphrase.value)
|
||||||
|
downloadBlob(blob, filename)
|
||||||
} finally {
|
} finally {
|
||||||
busyBackup.value = false
|
busyBackup.value = false
|
||||||
}
|
}
|
||||||
@@ -236,11 +298,11 @@ async function onBackupFileSelect(e) {
|
|||||||
const file = e.target.files[0]
|
const file = e.target.files[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
busyBackup.value = true
|
busyBackup.value = true
|
||||||
result.value = null
|
localResult.value = null
|
||||||
try {
|
try {
|
||||||
const summary = await store.importDataDump(file, backupPassphrase.value)
|
const summary = await store.importDataDump(file, backupPassphrase.value)
|
||||||
await mapsStore.fetchMaps()
|
await mapsStore.fetchMaps()
|
||||||
result.value = {
|
localResult.value = {
|
||||||
total: summary.importedContacts + summary.importedRelations,
|
total: summary.importedContacts + summary.importedRelations,
|
||||||
created: summary.importedContacts,
|
created: summary.importedContacts,
|
||||||
importedRelations: summary.importedRelations,
|
importedRelations: summary.importedRelations,
|
||||||
@@ -248,8 +310,9 @@ async function onBackupFileSelect(e) {
|
|||||||
errors: [],
|
errors: [],
|
||||||
isDump: true,
|
isDump: true,
|
||||||
}
|
}
|
||||||
|
await refreshLocalCount()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
result.value = { error: error?.message || 'Ошибка импорта бэкапа' }
|
localResult.value = { error: error?.message || 'Ошибка импорта бэкапа' }
|
||||||
} finally {
|
} finally {
|
||||||
busyBackup.value = false
|
busyBackup.value = false
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
@@ -258,32 +321,86 @@ async function onBackupFileSelect(e) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.content-narrow {
|
.format-examples {
|
||||||
max-width: 640px;
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.format-pre {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--green);
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.import-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.import-panel__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.import-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.import-badge--remote {
|
||||||
|
background: rgba(78, 204, 163, 0.12);
|
||||||
|
color: var(--green, #4ecca3);
|
||||||
}
|
}
|
||||||
.section-title {
|
.section-title {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.subsection-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-bottom: 6px;
|
margin: 0 0 8px;
|
||||||
}
|
}
|
||||||
.section-subtitle {
|
.section-subtitle {
|
||||||
margin-bottom: 18px;
|
margin-bottom: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
.drop-zone {
|
.import-mode-hint {
|
||||||
border: 2px dashed var(--border);
|
display: block;
|
||||||
border-radius: var(--radius);
|
margin-top: 4px;
|
||||||
padding: 36px 20px;
|
color: var(--accent);
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s, background 0.15s;
|
|
||||||
}
|
}
|
||||||
.drop-zone:hover, .drag-over {
|
.import-divider {
|
||||||
border-color: var(--accent);
|
margin: 20px 0;
|
||||||
background: var(--accent-dim);
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.export-row {
|
.import-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
flex-wrap: wrap;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.import-empty-hint,
|
||||||
|
.migration-link {
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
.server-status {
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
.server-status--ok {
|
||||||
|
color: var(--green, #4ecca3);
|
||||||
|
}
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.import-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user