@brftech/filex-core 0.19.0 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -4
- package/dist/filex-core.js +10626 -6501
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +94 -63
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index.d.ts +1026 -7
- package/dist/style.css +1 -1
- package/package.json +3 -3
- package/src/FileExplorer.vue +103 -68
- package/src/components/ConnectionGuideView.vue +333 -0
- package/src/components/ConnectionsPanel.vue +912 -0
- package/src/components/NFSExportsPanel.vue +283 -0
- package/src/components/S3KeysPanel.vue +381 -0
- package/src/components/SSHKeysPanel.vue +222 -0
- package/src/components/StorageFields.vue +362 -0
- package/src/components/TokensPanel.vue +191 -0
- package/src/components/UploadProgress.vue +5 -1
- package/src/composables/useConnections.ts +271 -0
- package/src/composables/useFileApi.ts +15 -2
- package/src/composables/useNFSExports.ts +148 -0
- package/src/composables/useS3Keys.ts +175 -0
- package/src/composables/useSSHKeys.ts +119 -0
- package/src/composables/useThumbs.ts +1 -1
- package/src/composables/useTokens.ts +121 -0
- package/src/composables/useUploadChunked.ts +433 -164
- package/src/index.ts +77 -2
- package/src/lib/connectionGuides.ts +1279 -0
- package/src/lib/realtime.ts +1 -1
- package/src/lib/uploadResume.ts +157 -0
- package/src/locales/en.ts +413 -0
- package/src/locales/tr.ts +416 -0
- package/src/modals/ConvertModal.vue +1 -1
- package/src/styles/base.css +12 -12
- package/src/types/Connections.ts +122 -0
- package/src/types/ExplorerConfig.ts +23 -2
- package/src/types/NFSExports.ts +47 -0
- package/src/types/S3Keys.ts +55 -0
- package/src/types/SSHKeys.ts +54 -0
- package/src/types/Tokens.ts +39 -0
package/src/FileExplorer.vue
CHANGED
|
@@ -25,7 +25,11 @@ import type {
|
|
|
25
25
|
} from './types/FileNode';
|
|
26
26
|
import { isExternalUsable } from './types/FileNode';
|
|
27
27
|
import { useFileApi, type GlobalSearchHit } from './composables/useFileApi';
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
useUploadChunked,
|
|
30
|
+
isStagedUnsupported,
|
|
31
|
+
type UploadJob,
|
|
32
|
+
} from './composables/useUploadChunked';
|
|
29
33
|
import { useSelection } from './composables/useSelection';
|
|
30
34
|
import { useKeyboardShortcuts } from './composables/useKeyboardShortcuts';
|
|
31
35
|
import { useLocale } from './composables/useLocale';
|
|
@@ -1493,7 +1497,7 @@ const contextActions = computed<ContextAction[]>(() => {
|
|
|
1493
1497
|
// for the virtual root sets currentPath to EMPTY string, not '/'.
|
|
1494
1498
|
// So the guard never fired and every mutation action leaked into
|
|
1495
1499
|
// the menu at the depo listing — including new-folder + paste,
|
|
1496
|
-
// which
|
|
1500
|
+
// which Ada called out in the most direct possible terms. Use
|
|
1497
1501
|
// the same empty-after-trim test as `atVirtualRoot` above.
|
|
1498
1502
|
const trimmedPath = (currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
|
|
1499
1503
|
const inStorageRoot = multiStorageRoot.value && trimmedPath === '';
|
|
@@ -1531,7 +1535,7 @@ const contextActions = computed<ContextAction[]>(() => {
|
|
|
1531
1535
|
|
|
1532
1536
|
// selectionActionList — the SINGLE source of truth for the actions offered on a
|
|
1533
1537
|
// selection. BOTH the right-click context menu AND the top toolbar render this
|
|
1534
|
-
// exact list so they can never drift apart (
|
|
1538
|
+
// exact list so they can never drift apart (Ada: "sağ klik menüyle üst menü
|
|
1535
1539
|
// tutmuyor"). The toolbar filters out dividers/hidden; the context menu shows
|
|
1536
1540
|
// them. Action handling is unified in dispatchItemAction().
|
|
1537
1541
|
function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
@@ -1940,13 +1944,25 @@ async function uploadFiles(list: File[]) {
|
|
|
1940
1944
|
if (list.length === 0) return;
|
|
1941
1945
|
}
|
|
1942
1946
|
/* /wiring:e2 */
|
|
1943
|
-
const canChunk = !!(api.endpoints.uploadInit && api.endpoints.uploadFinalize);
|
|
1944
1947
|
for (const f of list) {
|
|
1945
|
-
//
|
|
1946
|
-
//
|
|
1947
|
-
//
|
|
1948
|
-
//
|
|
1949
|
-
|
|
1948
|
+
// Anything above the chunk size goes on the STAGED path: chunked into
|
|
1949
|
+
// filex's own staging area, resumable across a dropped connection and — via
|
|
1950
|
+
// the bookmark in lib/uploadResume — across a reloaded tab. It works on
|
|
1951
|
+
// every driver, unlike the S3-presigned path it replaced. Small files keep
|
|
1952
|
+
// the single-POST fast path, and a server that has no staged path at all
|
|
1953
|
+
// falls back to it too.
|
|
1954
|
+
if (chunked.shouldChunk(f)) {
|
|
1955
|
+
const pending = chunked.resumableFor(qualify(currentPath.value), f);
|
|
1956
|
+
if (pending) {
|
|
1957
|
+
// Say so. An upload that silently starts over looks identical to one
|
|
1958
|
+
// that never happened, which is precisely the complaint.
|
|
1959
|
+
flashToast(
|
|
1960
|
+
t('upload.resuming', {
|
|
1961
|
+
name: f.name,
|
|
1962
|
+
percent: f.size > 0 ? Math.round((pending.offset / f.size) * 100) : 0,
|
|
1963
|
+
}),
|
|
1964
|
+
);
|
|
1965
|
+
}
|
|
1950
1966
|
if (await chunkedUpload(f)) continue;
|
|
1951
1967
|
}
|
|
1952
1968
|
await legacyUpload(f);
|
|
@@ -1954,13 +1970,13 @@ async function uploadFiles(list: File[]) {
|
|
|
1954
1970
|
await load();
|
|
1955
1971
|
}
|
|
1956
1972
|
|
|
1957
|
-
async function legacyUpload(file: File) {
|
|
1973
|
+
async function legacyUpload(file: File, dest?: string) {
|
|
1958
1974
|
// Register a progress row so the corner badge tracks the upload — large files
|
|
1959
1975
|
// fall back here from the chunked path, and previously showed no progress at
|
|
1960
1976
|
// all (the chunked placeholder was removed on init failure and the legacy
|
|
1961
1977
|
// POST tracked nothing, so the badge vanished mid-upload).
|
|
1962
1978
|
const id = crypto.randomUUID();
|
|
1963
|
-
const target = qualify(currentPath.value);
|
|
1979
|
+
const target = dest ?? qualify(currentPath.value);
|
|
1964
1980
|
uploadJobs.value = [
|
|
1965
1981
|
...uploadJobs.value,
|
|
1966
1982
|
{ id, file, path: target, totalBytes: file.size, uploadedBytes: 0, percent: 0, status: 'uploading', cancel() {} },
|
|
@@ -1996,36 +2012,45 @@ async function legacyUpload(file: File) {
|
|
|
1996
2012
|
}
|
|
1997
2013
|
|
|
1998
2014
|
/**
|
|
1999
|
-
* Attempt
|
|
2000
|
-
*
|
|
2001
|
-
* so the caller
|
|
2002
|
-
*
|
|
2003
|
-
*
|
|
2015
|
+
* Attempt a staged (chunked, resumable) upload. Returns `true` when it was
|
|
2016
|
+
* handled — including when it failed — and `false` ONLY when this server has no
|
|
2017
|
+
* staged path at all, so the caller may fall back to the single-POST upload.
|
|
2018
|
+
*
|
|
2019
|
+
* ⚠ The old version fell back on ANY error, which was harmless while the
|
|
2020
|
+
* chunked path was S3-only and failed at init. It is not harmless now: a staged
|
|
2021
|
+
* upload that dies at 90 % has bytes on the server and a bookmark to resume
|
|
2022
|
+
* from, and quietly re-POSTing the whole file would throw both away — the
|
|
2023
|
+
* "starts from zero" behaviour this change exists to remove. A real failure is
|
|
2024
|
+
* shown to the user instead, and picking the same file again continues it.
|
|
2004
2025
|
*/
|
|
2005
|
-
async function chunkedUpload(file: File): Promise<boolean> {
|
|
2006
|
-
// Register the progress row LAZILY — only once
|
|
2007
|
-
// actually moving. A
|
|
2008
|
-
//
|
|
2026
|
+
async function chunkedUpload(file: File, dest?: string): Promise<boolean> {
|
|
2027
|
+
// Register the progress row LAZILY — only once `begin` succeeded and bytes are
|
|
2028
|
+
// actually moving. A server with no staged path then shows no badge at all,
|
|
2029
|
+
// so the fallback's own badge is the only one the user sees (no
|
|
2009
2030
|
// appear-then-vanish flicker).
|
|
2010
2031
|
const id = crypto.randomUUID();
|
|
2011
2032
|
let registered = false;
|
|
2033
|
+
const patch = (job: UploadJob) => {
|
|
2034
|
+
if (!registered) {
|
|
2035
|
+
uploadJobs.value = [...uploadJobs.value, { ...job, id } as UploadJob];
|
|
2036
|
+
registered = true;
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
const idx = uploadJobs.value.findIndex((j) => j.id === id);
|
|
2040
|
+
if (idx !== -1) {
|
|
2041
|
+
const next = [...uploadJobs.value];
|
|
2042
|
+
next[idx] = { ...job, id } as UploadJob;
|
|
2043
|
+
uploadJobs.value = next;
|
|
2044
|
+
}
|
|
2045
|
+
};
|
|
2046
|
+
const target = dest ?? qualify(currentPath.value);
|
|
2012
2047
|
try {
|
|
2013
2048
|
await chunked.uploadFile({
|
|
2014
|
-
path:
|
|
2049
|
+
path: target,
|
|
2015
2050
|
file,
|
|
2016
2051
|
onProgress: (job) => {
|
|
2017
|
-
if (!registered)
|
|
2018
|
-
|
|
2019
|
-
uploadJobs.value = [...uploadJobs.value, { ...job, id } as UploadJob];
|
|
2020
|
-
registered = true;
|
|
2021
|
-
} else {
|
|
2022
|
-
const idx = uploadJobs.value.findIndex((j) => j.id === id);
|
|
2023
|
-
if (idx !== -1) {
|
|
2024
|
-
const next = [...uploadJobs.value];
|
|
2025
|
-
next[idx] = { ...job, id } as UploadJob;
|
|
2026
|
-
uploadJobs.value = next;
|
|
2027
|
-
}
|
|
2028
|
-
}
|
|
2052
|
+
if (!registered && job.status !== 'uploading' && job.uploadedBytes <= 0) return;
|
|
2053
|
+
patch(job);
|
|
2029
2054
|
emit('upload-progress', {
|
|
2030
2055
|
uploadId: job.uploadId ?? id,
|
|
2031
2056
|
percent: job.percent,
|
|
@@ -2034,9 +2059,32 @@ async function chunkedUpload(file: File): Promise<boolean> {
|
|
|
2034
2059
|
},
|
|
2035
2060
|
});
|
|
2036
2061
|
return true;
|
|
2037
|
-
} catch {
|
|
2038
|
-
if (
|
|
2039
|
-
|
|
2062
|
+
} catch (err) {
|
|
2063
|
+
if (isStagedUnsupported(err)) {
|
|
2064
|
+
if (registered) uploadJobs.value = uploadJobs.value.filter((j) => j.id !== id);
|
|
2065
|
+
return false;
|
|
2066
|
+
}
|
|
2067
|
+
const message = (err as Error).message;
|
|
2068
|
+
if (!registered) {
|
|
2069
|
+
uploadJobs.value = [
|
|
2070
|
+
...uploadJobs.value,
|
|
2071
|
+
{
|
|
2072
|
+
id,
|
|
2073
|
+
file,
|
|
2074
|
+
path: target,
|
|
2075
|
+
totalBytes: file.size,
|
|
2076
|
+
uploadedBytes: 0,
|
|
2077
|
+
percent: 0,
|
|
2078
|
+
status: 'error',
|
|
2079
|
+
error: message,
|
|
2080
|
+
cancel() {},
|
|
2081
|
+
} as UploadJob,
|
|
2082
|
+
];
|
|
2083
|
+
registered = true;
|
|
2084
|
+
}
|
|
2085
|
+
flashToast(t('upload.failed', { name: file.name }));
|
|
2086
|
+
emit('error', { message, context: { op: 'upload', file: file.name } });
|
|
2087
|
+
return true;
|
|
2040
2088
|
}
|
|
2041
2089
|
}
|
|
2042
2090
|
|
|
@@ -2471,42 +2519,29 @@ watch(
|
|
|
2471
2519
|
const opsCenter = useOperations();
|
|
2472
2520
|
|
|
2473
2521
|
/**
|
|
2474
|
-
* Retry a failed upload from the operations center. The failed row is
|
|
2475
|
-
*
|
|
2476
|
-
*
|
|
2477
|
-
*
|
|
2522
|
+
* Retry a failed upload from the operations center. The failed row is already
|
|
2523
|
+
* retired by the store; re-run the upload against the job's ORIGINAL target
|
|
2524
|
+
* folder (the user may have navigated away since).
|
|
2525
|
+
*
|
|
2526
|
+
* ⚠ It goes back through the SAME decision a fresh upload makes, rather than
|
|
2527
|
+
* straight to the single-POST path as it used to. A retry is the moment resume
|
|
2528
|
+
* matters most: the staged session and its bookmark are still there, so this
|
|
2529
|
+
* continues from filex's offset instead of pushing the whole file again.
|
|
2478
2530
|
*/
|
|
2479
2531
|
function retryUploadJob(job: UploadJob) {
|
|
2480
2532
|
uploadJobs.value = uploadJobs.value.filter((j) => j.id !== job.id);
|
|
2481
2533
|
const file = job.file;
|
|
2482
2534
|
const target = job.path || qualify(currentPath.value);
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
uploadJobs.value = next;
|
|
2494
|
-
};
|
|
2495
|
-
api
|
|
2496
|
-
.uploadMultipart(target, [file], (percent) => {
|
|
2497
|
-
patchRetry({ percent, uploadedBytes: Math.round((percent / 100) * file.size) });
|
|
2498
|
-
emit('upload-progress', { uploadId: id, percent, done: percent >= 100 });
|
|
2499
|
-
})
|
|
2500
|
-
.then(() => {
|
|
2501
|
-
patchRetry({ percent: 100, uploadedBytes: file.size, status: 'done' });
|
|
2502
|
-
emit('upload-progress', { uploadId: id, percent: 100, done: true });
|
|
2503
|
-
void load();
|
|
2504
|
-
})
|
|
2505
|
-
.catch((err: Error) => {
|
|
2506
|
-
patchRetry({ status: 'error', error: err.message });
|
|
2507
|
-
flashToast(t('upload.failed', { name: file.name }));
|
|
2508
|
-
emit('error', { message: err.message, context: { op: 'upload-retry', file: file.name } });
|
|
2509
|
-
});
|
|
2535
|
+
void (async () => {
|
|
2536
|
+
if (chunked.shouldChunk(file)) {
|
|
2537
|
+
if (await chunkedUpload(file, target)) {
|
|
2538
|
+
await load();
|
|
2539
|
+
return;
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
await legacyUpload(file, target);
|
|
2543
|
+
await load();
|
|
2544
|
+
})();
|
|
2510
2545
|
}
|
|
2511
2546
|
/* /wiring:c3 */
|
|
2512
2547
|
/* === wiring:c4 — onboarding coach-mark tour ===
|
|
@@ -2716,7 +2751,7 @@ function onPaneOpenTrash() {
|
|
|
2716
2751
|
}
|
|
2717
2752
|
/* ui-fix — pane'in KENDİ görünüm modu: split açılırken ana panelinkini
|
|
2718
2753
|
* devralır, sonrasında bağımsız. Toolbar'ın görünüm değiştiricisi ve palet
|
|
2719
|
-
* toggle'ı AKTİF panele yazar (
|
|
2754
|
+
* toggle'ı AKTİF panele yazar (Ada: "B tıklıyken ikon değiştir dersem
|
|
2720
2755
|
* B'nin değişmesi lazım"). */
|
|
2721
2756
|
const paneViewMode = computed<ViewMode>(() => activeSplit.value?.viewMode ?? viewMode.value);
|
|
2722
2757
|
function setPaneViewMode(v: ViewMode) {
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ConnectionGuideView — renders one `ProtocolGuide`.
|
|
4
|
+
*
|
|
5
|
+
* The guide itself is generated from the live deployment
|
|
6
|
+
* (`lib/connectionGuides.ts`); this component only draws it. Keeping the
|
|
7
|
+
* two apart is what lets S3 and SFTP arrive as a builder each rather than
|
|
8
|
+
* as a second page with its own copy buttons and its own bugs.
|
|
9
|
+
*/
|
|
10
|
+
import { computed, ref } from 'vue';
|
|
11
|
+
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
12
|
+
import type { GuideBlock, ProtocolGuide } from '../lib/connectionGuides';
|
|
13
|
+
import { useLocale } from '../composables/useLocale';
|
|
14
|
+
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
guide: ProtocolGuide;
|
|
17
|
+
locale: LocaleCode;
|
|
18
|
+
}>();
|
|
19
|
+
|
|
20
|
+
const { t } = useLocale(() => props.locale);
|
|
21
|
+
|
|
22
|
+
const activeClient = ref<string>(props.guide.clients[0]?.id ?? '');
|
|
23
|
+
const copied = ref<string | null>(null);
|
|
24
|
+
let copyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
25
|
+
|
|
26
|
+
const client = computed(
|
|
27
|
+
() => props.guide.clients.find((c) => c.id === activeClient.value) ?? props.guide.clients[0],
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Copy, with a fallback.
|
|
32
|
+
*
|
|
33
|
+
* `navigator.clipboard` needs a secure context. The desktop app's `app://`
|
|
34
|
+
* scheme is registered secure, and the web app is on https — but an embed
|
|
35
|
+
* on plain http is a real deployment too, and a copy button that silently
|
|
36
|
+
* does nothing there is worse than no button.
|
|
37
|
+
*/
|
|
38
|
+
async function copy(text: string, id: string) {
|
|
39
|
+
let ok = false;
|
|
40
|
+
try {
|
|
41
|
+
await navigator.clipboard.writeText(text);
|
|
42
|
+
ok = true;
|
|
43
|
+
} catch {
|
|
44
|
+
try {
|
|
45
|
+
const ta = document.createElement('textarea');
|
|
46
|
+
ta.value = text;
|
|
47
|
+
ta.setAttribute('readonly', '');
|
|
48
|
+
ta.style.position = 'fixed';
|
|
49
|
+
ta.style.opacity = '0';
|
|
50
|
+
document.body.appendChild(ta);
|
|
51
|
+
ta.select();
|
|
52
|
+
ok = document.execCommand('copy');
|
|
53
|
+
document.body.removeChild(ta);
|
|
54
|
+
} catch {
|
|
55
|
+
ok = false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
copied.value = ok ? id : null;
|
|
59
|
+
if (copyTimer) clearTimeout(copyTimer);
|
|
60
|
+
copyTimer = setTimeout(() => {
|
|
61
|
+
copied.value = null;
|
|
62
|
+
}, 1600);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function blockId(prefix: string, i: number): string {
|
|
66
|
+
return `${prefix}-${i}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isCode(b: GuideBlock): boolean {
|
|
70
|
+
return b.kind === 'code';
|
|
71
|
+
}
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<template>
|
|
75
|
+
<div class="fe-guide">
|
|
76
|
+
<p class="fe-guide__summary">{{ guide.summary }}</p>
|
|
77
|
+
|
|
78
|
+
<!-- The facts. This is the part that makes the page worth generating:
|
|
79
|
+
the real host, the real storage, the caller's own username. -->
|
|
80
|
+
<section class="fe-guide__facts" data-testid="guide-facts">
|
|
81
|
+
<div v-for="(f, i) in guide.facts" :key="f.label" class="fe-guide__fact">
|
|
82
|
+
<span class="fe-guide__factlabel">{{ f.label }}</span>
|
|
83
|
+
<span class="fe-guide__factvalue">
|
|
84
|
+
<code :class="{ 'fe-guide__ph': f.placeholderOnly }">{{ f.value }}</code>
|
|
85
|
+
<button
|
|
86
|
+
v-if="!f.placeholderOnly"
|
|
87
|
+
type="button"
|
|
88
|
+
class="fe-guide__copy"
|
|
89
|
+
:data-testid="`copy-fact-${i}`"
|
|
90
|
+
@click="copy(f.value, blockId('fact', i))"
|
|
91
|
+
>
|
|
92
|
+
{{ copied === blockId('fact', i) ? t('conn.guide.copied') : t('conn.guide.copy') }}
|
|
93
|
+
</button>
|
|
94
|
+
</span>
|
|
95
|
+
<span v-if="f.hint" class="fe-guide__facthint">{{ f.hint }}</span>
|
|
96
|
+
</div>
|
|
97
|
+
</section>
|
|
98
|
+
|
|
99
|
+
<!-- One tab per client. A "how to connect" page that covers one OS is a
|
|
100
|
+
page most readers bounce off. -->
|
|
101
|
+
<nav class="fe-guide__tabs" role="tablist">
|
|
102
|
+
<button
|
|
103
|
+
v-for="c in guide.clients"
|
|
104
|
+
:key="c.id"
|
|
105
|
+
type="button"
|
|
106
|
+
role="tab"
|
|
107
|
+
class="fe-guide__tab"
|
|
108
|
+
:class="{ 'is-active': client && c.id === client.id }"
|
|
109
|
+
:aria-selected="client && c.id === client.id"
|
|
110
|
+
:data-testid="`guide-tab-${c.id}`"
|
|
111
|
+
@click="activeClient = c.id"
|
|
112
|
+
>
|
|
113
|
+
{{ c.name }}
|
|
114
|
+
</button>
|
|
115
|
+
</nav>
|
|
116
|
+
|
|
117
|
+
<div v-if="client" class="fe-guide__body" role="tabpanel">
|
|
118
|
+
<template v-for="(b, i) in client.blocks" :key="i">
|
|
119
|
+
<ol v-if="b.kind === 'steps'" class="fe-guide__steps">
|
|
120
|
+
<li v-for="(s, j) in b.steps ?? []" :key="j">{{ s }}</li>
|
|
121
|
+
</ol>
|
|
122
|
+
<div v-else-if="isCode(b)" class="fe-guide__codewrap">
|
|
123
|
+
<div class="fe-guide__codehead">
|
|
124
|
+
<span class="fe-guide__caption">{{ b.caption }}</span>
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
class="fe-guide__copy"
|
|
128
|
+
:data-testid="`copy-code-${i}`"
|
|
129
|
+
@click="copy(b.code ?? '', blockId(client.id, i))"
|
|
130
|
+
>
|
|
131
|
+
{{ copied === blockId(client.id, i) ? t('conn.guide.copied') : t('conn.guide.copy') }}
|
|
132
|
+
</button>
|
|
133
|
+
</div>
|
|
134
|
+
<pre class="fe-guide__code"><code>{{ b.code }}</code></pre>
|
|
135
|
+
</div>
|
|
136
|
+
<p v-else-if="b.kind === 'warn'" class="fe-guide__warn">{{ b.text }}</p>
|
|
137
|
+
<p v-else-if="b.kind === 'note'" class="fe-guide__note">{{ b.text }}</p>
|
|
138
|
+
<p v-else class="fe-guide__text">{{ b.text }}</p>
|
|
139
|
+
</template>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<section v-if="guide.notes.length" class="fe-guide__notes">
|
|
143
|
+
<h4 class="fe-guide__notestitle">{{ t('conn.guide.goodToKnow') }}</h4>
|
|
144
|
+
<p
|
|
145
|
+
v-for="(n, i) in guide.notes"
|
|
146
|
+
:key="i"
|
|
147
|
+
:class="n.kind === 'warn' ? 'fe-guide__warn' : 'fe-guide__note'"
|
|
148
|
+
>
|
|
149
|
+
{{ n.text }}
|
|
150
|
+
</p>
|
|
151
|
+
</section>
|
|
152
|
+
</div>
|
|
153
|
+
</template>
|
|
154
|
+
|
|
155
|
+
<style>
|
|
156
|
+
.fe-guide {
|
|
157
|
+
display: flex;
|
|
158
|
+
flex-direction: column;
|
|
159
|
+
gap: 16px;
|
|
160
|
+
}
|
|
161
|
+
.fe-guide__summary {
|
|
162
|
+
margin: 0;
|
|
163
|
+
color: var(--fe-text-muted);
|
|
164
|
+
font-size: 13.5px;
|
|
165
|
+
line-height: 1.55;
|
|
166
|
+
}
|
|
167
|
+
.fe-guide__facts {
|
|
168
|
+
display: flex;
|
|
169
|
+
flex-direction: column;
|
|
170
|
+
gap: 10px;
|
|
171
|
+
border: 1px solid var(--fe-border);
|
|
172
|
+
border-radius: var(--fe-radius);
|
|
173
|
+
background: var(--fe-bg-elev);
|
|
174
|
+
padding: 12px 14px;
|
|
175
|
+
}
|
|
176
|
+
.fe-guide__fact {
|
|
177
|
+
display: grid;
|
|
178
|
+
grid-template-columns: minmax(120px, 180px) 1fr;
|
|
179
|
+
gap: 4px 14px;
|
|
180
|
+
align-items: baseline;
|
|
181
|
+
}
|
|
182
|
+
.fe-guide__factlabel {
|
|
183
|
+
font-size: 12.5px;
|
|
184
|
+
font-weight: 600;
|
|
185
|
+
color: var(--fe-text-muted);
|
|
186
|
+
}
|
|
187
|
+
.fe-guide__factvalue {
|
|
188
|
+
display: flex;
|
|
189
|
+
align-items: center;
|
|
190
|
+
gap: 8px;
|
|
191
|
+
min-width: 0;
|
|
192
|
+
flex-wrap: wrap;
|
|
193
|
+
}
|
|
194
|
+
.fe-guide__factvalue code {
|
|
195
|
+
font-family: var(--fe-font-mono);
|
|
196
|
+
font-size: 12.5px;
|
|
197
|
+
color: var(--fe-text);
|
|
198
|
+
background: var(--fe-bg);
|
|
199
|
+
border: 1px solid var(--fe-border);
|
|
200
|
+
border-radius: var(--fe-radius-sm);
|
|
201
|
+
padding: 2px 6px;
|
|
202
|
+
overflow-wrap: anywhere;
|
|
203
|
+
}
|
|
204
|
+
.fe-guide__ph {
|
|
205
|
+
color: var(--fe-text-muted) !important;
|
|
206
|
+
font-style: italic;
|
|
207
|
+
}
|
|
208
|
+
.fe-guide__facthint {
|
|
209
|
+
grid-column: 2;
|
|
210
|
+
font-size: 12px;
|
|
211
|
+
color: var(--fe-text-muted);
|
|
212
|
+
}
|
|
213
|
+
.fe-guide__tabs {
|
|
214
|
+
display: flex;
|
|
215
|
+
gap: 4px;
|
|
216
|
+
flex-wrap: wrap;
|
|
217
|
+
border-bottom: 1px solid var(--fe-border);
|
|
218
|
+
padding-bottom: 2px;
|
|
219
|
+
}
|
|
220
|
+
.fe-guide__tab {
|
|
221
|
+
font: inherit;
|
|
222
|
+
font-size: 13px;
|
|
223
|
+
border: 0;
|
|
224
|
+
background: none;
|
|
225
|
+
color: var(--fe-text-muted);
|
|
226
|
+
padding: 6px 10px;
|
|
227
|
+
border-radius: var(--fe-radius-sm) var(--fe-radius-sm) 0 0;
|
|
228
|
+
cursor: pointer;
|
|
229
|
+
border-bottom: 2px solid transparent;
|
|
230
|
+
}
|
|
231
|
+
.fe-guide__tab:hover {
|
|
232
|
+
color: var(--fe-text);
|
|
233
|
+
background: var(--fe-bg-hover);
|
|
234
|
+
}
|
|
235
|
+
.fe-guide__tab.is-active {
|
|
236
|
+
color: var(--fe-primary);
|
|
237
|
+
border-bottom-color: var(--fe-primary);
|
|
238
|
+
font-weight: 600;
|
|
239
|
+
}
|
|
240
|
+
.fe-guide__body {
|
|
241
|
+
display: flex;
|
|
242
|
+
flex-direction: column;
|
|
243
|
+
gap: 12px;
|
|
244
|
+
}
|
|
245
|
+
.fe-guide__steps {
|
|
246
|
+
margin: 0;
|
|
247
|
+
padding-left: 20px;
|
|
248
|
+
display: flex;
|
|
249
|
+
flex-direction: column;
|
|
250
|
+
gap: 6px;
|
|
251
|
+
font-size: 13.5px;
|
|
252
|
+
line-height: 1.55;
|
|
253
|
+
color: var(--fe-text);
|
|
254
|
+
}
|
|
255
|
+
.fe-guide__codewrap {
|
|
256
|
+
border: 1px solid var(--fe-border);
|
|
257
|
+
border-radius: var(--fe-radius);
|
|
258
|
+
overflow: hidden;
|
|
259
|
+
background: var(--fe-bg-elev);
|
|
260
|
+
}
|
|
261
|
+
.fe-guide__codehead {
|
|
262
|
+
display: flex;
|
|
263
|
+
align-items: center;
|
|
264
|
+
justify-content: space-between;
|
|
265
|
+
gap: 10px;
|
|
266
|
+
padding: 6px 10px;
|
|
267
|
+
border-bottom: 1px solid var(--fe-border);
|
|
268
|
+
}
|
|
269
|
+
.fe-guide__caption {
|
|
270
|
+
font-size: 12px;
|
|
271
|
+
color: var(--fe-text-muted);
|
|
272
|
+
font-family: var(--fe-font-mono);
|
|
273
|
+
overflow-wrap: anywhere;
|
|
274
|
+
}
|
|
275
|
+
.fe-guide__code {
|
|
276
|
+
margin: 0;
|
|
277
|
+
padding: 10px 12px;
|
|
278
|
+
/* Wide command lines scroll INSIDE the block. Without this the whole
|
|
279
|
+
settings surface grows a horizontal scrollbar and the layout around
|
|
280
|
+
it breaks — the same failure the tab strip had. */
|
|
281
|
+
overflow-x: auto;
|
|
282
|
+
font-family: var(--fe-font-mono);
|
|
283
|
+
font-size: 12.5px;
|
|
284
|
+
line-height: 1.6;
|
|
285
|
+
color: var(--fe-text);
|
|
286
|
+
white-space: pre;
|
|
287
|
+
}
|
|
288
|
+
.fe-guide__copy {
|
|
289
|
+
font: inherit;
|
|
290
|
+
font-size: 12px;
|
|
291
|
+
border: 1px solid var(--fe-border-strong);
|
|
292
|
+
background: var(--fe-bg);
|
|
293
|
+
color: var(--fe-text);
|
|
294
|
+
border-radius: var(--fe-radius-sm);
|
|
295
|
+
padding: 3px 9px;
|
|
296
|
+
cursor: pointer;
|
|
297
|
+
flex: 0 0 auto;
|
|
298
|
+
}
|
|
299
|
+
.fe-guide__copy:hover {
|
|
300
|
+
border-color: var(--fe-primary);
|
|
301
|
+
color: var(--fe-primary);
|
|
302
|
+
}
|
|
303
|
+
.fe-guide__note,
|
|
304
|
+
.fe-guide__warn,
|
|
305
|
+
.fe-guide__text {
|
|
306
|
+
margin: 0;
|
|
307
|
+
font-size: 12.5px;
|
|
308
|
+
line-height: 1.55;
|
|
309
|
+
}
|
|
310
|
+
.fe-guide__text {
|
|
311
|
+
color: var(--fe-text);
|
|
312
|
+
}
|
|
313
|
+
.fe-guide__note {
|
|
314
|
+
color: var(--fe-text-muted);
|
|
315
|
+
}
|
|
316
|
+
.fe-guide__warn {
|
|
317
|
+
color: var(--fe-danger);
|
|
318
|
+
}
|
|
319
|
+
.fe-guide__notes {
|
|
320
|
+
display: flex;
|
|
321
|
+
flex-direction: column;
|
|
322
|
+
gap: 6px;
|
|
323
|
+
border-top: 1px solid var(--fe-border);
|
|
324
|
+
padding-top: 12px;
|
|
325
|
+
}
|
|
326
|
+
.fe-guide__notestitle {
|
|
327
|
+
margin: 0;
|
|
328
|
+
font-size: 12px;
|
|
329
|
+
text-transform: uppercase;
|
|
330
|
+
letter-spacing: 0.05em;
|
|
331
|
+
color: var(--fe-text-muted);
|
|
332
|
+
}
|
|
333
|
+
</style>
|