1527 lines
77 KiB
PHP
1527 lines
77 KiB
PHP
@php
|
|
$nodes = $nodes ?? config('nas-file-manager.schema', []);
|
|
$canEdit = $canEdit ?? (config('nas-file-manager.edit_gate') === null || \Illuminate\Support\Facades\Gate::allows(config('nas-file-manager.edit_gate')));
|
|
$title = $title ?? 'Folder Structure & File Manager';
|
|
|
|
// Build connections — DB rows take priority, then config, then legacy key
|
|
$rawConns = \P7H\NasFileManager\Models\NasConnection::allAsConfig()
|
|
?? config('nas-file-manager.connections', []);
|
|
|
|
if (empty($rawConns)) {
|
|
$lc = config('nas-file-manager.connection', []);
|
|
$rawConns = [[
|
|
'name' => 'Primary NAS',
|
|
'enabled' => true,
|
|
'protocol' => $lc['protocol'] ?? 'sftp',
|
|
'host' => $lc['host'] ?? '',
|
|
'port' => (int)($lc['port'] ?? 22),
|
|
'username' => $lc['username'] ?? '',
|
|
'password' => $lc['password'] ?? '',
|
|
'share' => $lc['smb_share'] ?? '',
|
|
'smb_domain' => $lc['smb_domain'] ?? '',
|
|
'subdirectory' => $lc['path'] ?? '/media',
|
|
]];
|
|
}
|
|
|
|
$connectionsJs = collect($rawConns)->values()->map(fn($c, $i) => [
|
|
'_id' => $c['db_id'] ?? ($i + 1),
|
|
'db_id' => $c['db_id'] ?? null,
|
|
'name' => $c['name'] ?? ('Connection ' . ($i + 1)),
|
|
'enabled' => (bool)($c['enabled'] ?? true),
|
|
'expanded' => $i === 0,
|
|
'protocol' => $c['protocol'] ?? 'sftp',
|
|
'host' => $c['host'] ?? '',
|
|
'port' => (int)($c['port'] ?? 22),
|
|
'username' => $c['username'] ?? '',
|
|
'password' => '',
|
|
'has_password' => !empty($c['password']),
|
|
'share' => $c['share'] ?? ($c['smb_share'] ?? ''),
|
|
'smb_domain' => $c['smb_domain'] ?? '',
|
|
'subdirectory' => $c['subdirectory'] ?? ($c['path'] ?? '/media'),
|
|
'testStatus' => null,
|
|
'testMessage' => '',
|
|
'saveStatus' => null,
|
|
'saveMessage' => '',
|
|
'pickerOpen' => false,
|
|
'pickerItems' => [],
|
|
'pickerPath' => '',
|
|
'pickerSegments' => [],
|
|
'pickerLoading' => false,
|
|
'pickerError' => '',
|
|
'shares' => [],
|
|
'sharesLoading' => false,
|
|
])->all();
|
|
|
|
$hasConnection = collect($rawConns)->contains(fn($c) => !empty($c['host']));
|
|
@endphp
|
|
|
|
<style>
|
|
.nas-fm { color: var(--text); }
|
|
|
|
/* ── Accordion header ─────────────────────────────────────── */
|
|
.nas-accordion-btn {
|
|
width: 100%;
|
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
|
padding: 14px 20px;
|
|
background: transparent; border: none;
|
|
text-align: left; cursor: pointer;
|
|
border-bottom: 1px solid transparent;
|
|
transition: background .15s;
|
|
}
|
|
.nas-accordion-btn:hover { background: rgba(255,255,255,.02); }
|
|
.nas-accordion-btn.open { border-bottom-color: var(--border); }
|
|
.nas-accordion-icon {
|
|
width: 28px; height: 28px; border-radius: 8px;
|
|
background: rgba(245,158,11,.12);
|
|
display: flex; align-items: center; justify-content: center;
|
|
flex-shrink: 0;
|
|
color: #f59e0b; font-size: 14px;
|
|
}
|
|
.nas-accordion-title { font-size: 13px; font-weight: 600; color: var(--text); }
|
|
.nas-accordion-sub { font-size: 11px; color: var(--text-2); margin-top: 1px; }
|
|
.nas-accordion-sub.warn { color: #f59e0b; }
|
|
.nas-accordion-chevron {
|
|
font-size: 11px; color: var(--text-3);
|
|
transition: transform .2s;
|
|
flex-shrink: 0;
|
|
}
|
|
.nas-accordion-chevron.open { transform: rotate(180deg); }
|
|
.nas-setup-badge {
|
|
display: inline-flex; align-items: center; gap: 4px;
|
|
padding: 2px 8px; border-radius: 20px; font-size: 10px; font-weight: 700;
|
|
background: rgba(245,158,11,.15); color: #f59e0b;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* ── Tabs ───────────────────────────────────────────────────── */
|
|
.nas-tabs {
|
|
display: flex; gap: 4px;
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 0 20px;
|
|
}
|
|
.nas-tab {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 10px 14px;
|
|
font-size: 12px; font-weight: 500;
|
|
border: none; background: transparent;
|
|
color: var(--text-2);
|
|
border-bottom: 2px solid transparent;
|
|
margin-bottom: -1px; cursor: pointer;
|
|
transition: color .15s, border-color .15s;
|
|
}
|
|
.nas-tab:hover { color: var(--text); }
|
|
.nas-tab.active { color: var(--text); border-bottom-color: var(--brand); }
|
|
.nas-tab i { font-size: 13px; }
|
|
.nas-dot { width: 6px; height: 6px; border-radius: 50%; background: #f59e0b; flex-shrink: 0; }
|
|
|
|
/* ── Tab panels ─────────────────────────────────────────────── */
|
|
.nas-panel { padding: 20px; display: none; }
|
|
.nas-panel.active { display: block; }
|
|
|
|
/* ── Status / notice banners ────────────────────────────────── */
|
|
.nas-status {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 10px 14px; border-radius: 8px;
|
|
font-size: 12px; margin-bottom: 16px; border: 1px solid;
|
|
}
|
|
.nas-status.testing { background: rgba(148,163,184,.08); border-color: var(--border-light); color: var(--text-2); }
|
|
.nas-status.ok { background: rgba(34,197,94,.08); border-color: rgba(34,197,94,.2); color: #86efac; }
|
|
.nas-status.fail { background: rgba(248,113,113,.08); border-color: rgba(248,113,113,.2); color: #fca5a5; }
|
|
.nas-status.saving { background: rgba(148,163,184,.08); border-color: var(--border-light); color: var(--text-2); }
|
|
.nas-status.saved { background: rgba(34,197,94,.08); border-color: rgba(34,197,94,.2); color: #86efac; }
|
|
.nas-status.error { background: rgba(248,113,113,.08); border-color: rgba(248,113,113,.2); color: #fca5a5; }
|
|
.nas-status i { font-size: 14px; flex-shrink: 0; }
|
|
.nas-notice {
|
|
display: flex; align-items: flex-start; gap: 10px;
|
|
padding: 12px 14px; border-radius: 8px;
|
|
background: rgba(251,191,36,.06); border: 1px solid rgba(251,191,36,.2);
|
|
color: #fcd34d; font-size: 12px; margin-bottom: 16px; line-height: 1.6;
|
|
}
|
|
.nas-notice i { flex-shrink: 0; margin-top: 1px; }
|
|
.nas-notice code {
|
|
font-family: monospace; background: rgba(251,191,36,.12);
|
|
padding: 1px 5px; border-radius: 4px; font-size: 11px; color: #fde68a;
|
|
}
|
|
|
|
/* ── Form fields ─────────────────────────────────────────────── */
|
|
.nas-field { margin-bottom: 14px; }
|
|
.nas-field label {
|
|
display: block; font-size: 11px; font-weight: 600;
|
|
letter-spacing: .4px; text-transform: uppercase;
|
|
color: var(--text-2); margin-bottom: 6px;
|
|
}
|
|
.nas-field label .nas-req { color: var(--brand); }
|
|
.nas-input {
|
|
width: 100%; height: 36px; padding: 0 12px;
|
|
background: var(--bg); border: 1px solid var(--border-light);
|
|
border-radius: 8px; color: var(--text);
|
|
font-size: 13px; font-family: monospace;
|
|
outline: none; transition: border-color .2s; box-sizing: border-box;
|
|
}
|
|
.nas-input:focus { border-color: var(--brand); }
|
|
.nas-input::placeholder { color: var(--text-3); }
|
|
.nas-input-wrap { position: relative; }
|
|
.nas-input-wrap .nas-input { padding-right: 36px; }
|
|
.nas-eye {
|
|
position: absolute; right: 0; top: 0; bottom: 0;
|
|
width: 36px; display: flex; align-items: center; justify-content: center;
|
|
background: transparent; border: none; color: var(--text-2);
|
|
cursor: pointer; font-size: 14px; transition: color .15s;
|
|
}
|
|
.nas-eye:hover { color: var(--text); }
|
|
.nas-hint { font-size: 11px; color: var(--text-3); margin-top: 4px; }
|
|
.nas-hint.ok { color: #86efac; }
|
|
.nas-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
.nas-grid .nas-field { margin-bottom: 0; }
|
|
.nas-grid .span2 { grid-column: 1 / -1; }
|
|
@media (max-width: 600px) { .nas-grid { grid-template-columns: 1fr; } .nas-grid .span2 { grid-column: 1; } }
|
|
|
|
/* ── Protocol pills ───────────────────────────────────────────── */
|
|
.nas-proto-group { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
.nas-proto {
|
|
padding: 5px 14px; border-radius: 20px;
|
|
font-size: 11px; font-weight: 700; font-family: monospace;
|
|
letter-spacing: .5px; text-transform: uppercase;
|
|
border: 1px solid var(--border-light);
|
|
background: transparent; color: var(--text-2);
|
|
cursor: pointer; transition: all .15s;
|
|
}
|
|
.nas-proto:hover { border-color: var(--brand); color: var(--text); }
|
|
.nas-proto.active { background: var(--brand); border-color: var(--brand); color: #fff; }
|
|
|
|
/* ── Connection cards ─────────────────────────────────────────── */
|
|
.nas-conn-card {
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px; overflow: hidden;
|
|
margin-bottom: 10px; transition: opacity .2s;
|
|
}
|
|
.nas-conn-card.disabled { opacity: .55; }
|
|
.nas-conn-card-header {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 10px 14px; background: var(--bg);
|
|
cursor: pointer; user-select: none;
|
|
border-bottom: 1px solid transparent;
|
|
transition: background .1s;
|
|
}
|
|
.nas-conn-card-header:hover { background: rgba(255,255,255,.025); }
|
|
.nas-conn-card-header.open { border-bottom-color: var(--border); }
|
|
.nas-conn-card-chevron {
|
|
font-size: 10px; color: var(--text-3);
|
|
transition: transform .15s; flex-shrink: 0;
|
|
}
|
|
.nas-conn-card-chevron.open { transform: rotate(90deg); }
|
|
.nas-conn-name {
|
|
flex: 1; min-width: 0;
|
|
background: transparent; border: none; outline: none;
|
|
font-size: 12px; font-weight: 600; color: var(--text);
|
|
padding: 2px 4px; border-radius: 4px;
|
|
transition: background .15s, box-shadow .15s;
|
|
}
|
|
.nas-conn-name:focus {
|
|
background: var(--bg-card2);
|
|
box-shadow: 0 0 0 1px var(--brand);
|
|
}
|
|
.nas-conn-badges { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
|
.nas-proto-badge {
|
|
font-family: monospace; font-size: 10px; font-weight: 700;
|
|
text-transform: uppercase; letter-spacing: .5px;
|
|
padding: 2px 6px; border-radius: 4px;
|
|
background: var(--bg-card2); color: var(--text-2);
|
|
}
|
|
.nas-host-badge { font-size: 11px; color: var(--text-3); font-family: monospace; }
|
|
.nas-test-dot {
|
|
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
|
}
|
|
.nas-test-dot.ok { background: #4ade80; }
|
|
.nas-test-dot.fail { background: #f87171; }
|
|
.nas-test-dot.testing { background: #fbbf24; animation: nas-pulse 1s ease-in-out infinite; }
|
|
@keyframes nas-pulse { 0%,100% { opacity:1; } 50% { opacity:.4; } }
|
|
.nas-toggle {
|
|
position: relative; display: inline-flex;
|
|
width: 34px; height: 18px; border-radius: 9px;
|
|
background: var(--border-light);
|
|
border: none; cursor: pointer; transition: background .2s;
|
|
flex-shrink: 0;
|
|
}
|
|
.nas-toggle.on { background: #4ade80; }
|
|
.nas-toggle-thumb {
|
|
position: absolute; top: 2px; left: 2px;
|
|
width: 14px; height: 14px; border-radius: 50%;
|
|
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3);
|
|
transition: transform .2s;
|
|
}
|
|
.nas-toggle.on .nas-toggle-thumb { transform: translateX(16px); }
|
|
|
|
.nas-conn-body { padding: 16px; background: var(--bg-card2 ,var(--bg)); }
|
|
|
|
/* ── Subdirectory picker ────────────────────────────────────────── */
|
|
.nas-path-row { display: flex; gap: 8px; align-items: center; }
|
|
.nas-path-row .nas-input { flex: 1; }
|
|
.nas-picker-wrap {
|
|
margin-top: 8px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px; overflow: hidden;
|
|
}
|
|
.nas-picker-toolbar {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 8px 12px; background: var(--bg);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.nas-picker-breadcrumb {
|
|
flex: 1; display: flex; align-items: center;
|
|
gap: 4px; overflow-x: auto; font-size: 12px;
|
|
}
|
|
.nas-picker-crumb-btn {
|
|
background: none; border: none; color: var(--brand);
|
|
cursor: pointer; padding: 0; font-size: 12px;
|
|
white-space: nowrap; flex-shrink: 0;
|
|
}
|
|
.nas-picker-crumb-btn:hover { text-decoration: underline; }
|
|
.nas-picker-sep { color: var(--text-3); font-size: 10px; flex-shrink: 0; }
|
|
.nas-picker-select-btn {
|
|
flex-shrink: 0;
|
|
display: inline-flex; align-items: center; gap: 4px;
|
|
padding: 3px 10px; border-radius: 6px; font-size: 11px; font-weight: 600;
|
|
background: rgba(34,197,94,.15); color: #86efac;
|
|
border: 1px solid rgba(34,197,94,.25); cursor: pointer;
|
|
transition: background .15s;
|
|
}
|
|
.nas-picker-select-btn:hover { background: rgba(34,197,94,.25); }
|
|
.nas-picker-reload {
|
|
width: 26px; height: 26px; display: flex; align-items: center; justify-content: center;
|
|
background: transparent; border: 1px solid var(--border-light);
|
|
border-radius: 6px; color: var(--text-2); cursor: pointer; font-size: 12px;
|
|
transition: all .15s; flex-shrink: 0;
|
|
}
|
|
.nas-picker-reload:hover { border-color: var(--brand); color: var(--text); }
|
|
.nas-picker-list { max-height: 180px; overflow-y: auto; }
|
|
.nas-picker-item {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
|
cursor: pointer; transition: background .1s;
|
|
}
|
|
.nas-picker-item:last-child { border-bottom: none; }
|
|
.nas-picker-item:hover { background: rgba(255,255,255,.03); }
|
|
.nas-picker-item i { color: #f59e0b; font-size: 14px; flex-shrink: 0; }
|
|
.nas-picker-item-name {
|
|
flex: 1; font-size: 12px; font-family: monospace; color: var(--text);
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
.nas-picker-item:hover .nas-picker-item-name { color: var(--brand); }
|
|
.nas-picker-item-select {
|
|
opacity: 0; display: inline-flex; align-items: center; gap: 3px;
|
|
padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 600;
|
|
background: rgba(34,197,94,.15); color: #86efac; border: none; cursor: pointer;
|
|
transition: opacity .1s;
|
|
}
|
|
.nas-picker-item:hover .nas-picker-item-select { opacity: 1; }
|
|
.nas-picker-empty {
|
|
padding: 24px 12px; text-align: center;
|
|
font-size: 12px; color: var(--text-3);
|
|
}
|
|
.nas-picker-error {
|
|
padding: 8px 12px; font-size: 12px;
|
|
background: rgba(248,113,113,.08); color: #fca5a5;
|
|
}
|
|
|
|
/* ── Card actions row ─────────────────────────────────────────── */
|
|
.nas-card-actions {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
gap: 12px; padding-top: 14px;
|
|
border-top: 1px solid var(--border); margin-top: 14px;
|
|
}
|
|
.nas-remove-btn {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
font-size: 12px; color: var(--text-3);
|
|
background: none; border: none; cursor: pointer;
|
|
transition: color .15s;
|
|
}
|
|
.nas-remove-btn:hover { color: #f87171; }
|
|
.nas-card-btn-group { display: flex; align-items: center; gap: 8px; }
|
|
|
|
/* ── Split save button ─────────────────────────────────────────── */
|
|
.nas-split-btn { display: flex; align-items: stretch; border-radius: 8px; overflow: hidden; }
|
|
.nas-split-main {
|
|
border-radius: 8px 0 0 8px !important;
|
|
border-right: 1px solid rgba(255,255,255,.2) !important;
|
|
}
|
|
.nas-split-chevron {
|
|
display: flex; align-items: center; justify-content: center;
|
|
width: 32px; border-radius: 0 8px 8px 0 !important;
|
|
padding: 0 !important; font-size: 10px;
|
|
border-left: none !important;
|
|
}
|
|
.nas-split-dropdown {
|
|
position: absolute; right: 0; bottom: calc(100% + 6px);
|
|
width: 260px;
|
|
background: var(--bg-card2, var(--bg));
|
|
border: 1px solid var(--border-light);
|
|
border-radius: 10px; overflow: hidden;
|
|
box-shadow: 0 12px 40px rgba(0,0,0,.5);
|
|
z-index: 50;
|
|
}
|
|
.nas-split-dropdown-header {
|
|
padding: 10px 14px 6px;
|
|
font-size: 10px; font-weight: 700;
|
|
letter-spacing: .5px; text-transform: uppercase;
|
|
color: var(--text-3);
|
|
}
|
|
.nas-split-option {
|
|
display: flex; align-items: flex-start; gap: 10px;
|
|
padding: 10px 14px; cursor: pointer; width: 100%;
|
|
background: none; border: none; text-align: left;
|
|
transition: background .1s;
|
|
}
|
|
.nas-split-option:hover:not(:disabled) { background: rgba(255,255,255,.04); }
|
|
.nas-split-option:disabled { opacity: .5; cursor: not-allowed; }
|
|
.nas-split-option-icon {
|
|
width: 28px; height: 28px; border-radius: 6px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-size: 14px; flex-shrink: 0; margin-top: 1px;
|
|
}
|
|
.nas-split-option-icon.db { background: rgba(34,197,94,.12); color: #86efac; }
|
|
.nas-split-option-icon.env { background: var(--border); color: var(--text-2); }
|
|
.nas-split-option-title { font-size: 12px; font-weight: 600; color: var(--text); }
|
|
.nas-split-option-desc { font-size: 11px; color: var(--text-3); margin-top: 2px; line-height: 1.5; }
|
|
.nas-split-option-desc code {
|
|
font-family: monospace; font-size: 10px;
|
|
background: var(--border); padding: 1px 4px; border-radius: 3px; color: var(--text-2);
|
|
}
|
|
.nas-split-divider { border: none; border-top: 1px solid var(--border); margin: 0; }
|
|
.nas-split-footer { padding: 8px 14px; font-size: 11px; color: var(--text-3); line-height: 1.5; }
|
|
|
|
/* ── Add connection button ─────────────────────────────────────── */
|
|
.nas-add-conn-btn {
|
|
width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
|
|
padding: 12px; border-radius: 8px;
|
|
border: 1px dashed var(--border); background: transparent;
|
|
font-size: 12px; font-weight: 500; color: var(--text-3);
|
|
cursor: pointer; transition: all .15s;
|
|
}
|
|
.nas-add-conn-btn:hover { border-color: var(--brand); color: var(--brand); background: rgba(var(--brand-rgb, 220,38,38),.04); }
|
|
|
|
/* ── Schema tree ──────────────────────────────────────────────── */
|
|
.nas-schema-tree {
|
|
background: #0d1117; border: 1px solid #30363d;
|
|
border-radius: 8px; padding: 14px;
|
|
font-family: monospace; font-size: 13px;
|
|
overflow-x: auto;
|
|
}
|
|
.nas-schema-row {
|
|
display: flex; align-items: center;
|
|
line-height: 1.65; white-space: nowrap;
|
|
cursor: default;
|
|
}
|
|
.nas-schema-row.is-dir { cursor: pointer; }
|
|
.nas-schema-row.is-dir:hover .nas-schema-name { color: #f0a300; }
|
|
.nas-schema-prefix { color: #484f58; white-space: pre; flex-shrink: 0; }
|
|
.nas-schema-name { color: #fbbf24; }
|
|
.nas-schema-name.root { color: #e3b341; font-weight: 600; }
|
|
.nas-schema-name.file { color: #8b949e; }
|
|
.nas-schema-slash { color: #484f58; }
|
|
.nas-schema-size { margin-left: 12px; font-size: 11px; color: #484f58; flex-shrink: 0; }
|
|
.nas-static-tree {
|
|
background: var(--bg); border: 1px solid var(--border);
|
|
border-radius: 8px; padding: 14px;
|
|
font-family: monospace; font-size: 12px; overflow-x: auto;
|
|
}
|
|
|
|
/* ── File browser ─────────────────────────────────────────────── */
|
|
.nas-breadcrumb {
|
|
display: flex; align-items: center; gap: 6px;
|
|
flex-wrap: wrap; margin-bottom: 12px;
|
|
}
|
|
.nas-breadcrumb-btn {
|
|
background: none; border: none; font-size: 12px;
|
|
color: var(--brand); cursor: pointer; padding: 0;
|
|
}
|
|
.nas-breadcrumb-btn:hover { text-decoration: underline; }
|
|
.nas-breadcrumb-sep { color: var(--text-3); font-size: 10px; }
|
|
.nas-reload {
|
|
margin-left: auto; width: 28px; height: 28px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
background: transparent; border: 1px solid var(--border-light);
|
|
border-radius: 6px; color: var(--text-2); font-size: 13px;
|
|
cursor: pointer; transition: all .15s; flex-shrink: 0;
|
|
}
|
|
.nas-reload:hover { border-color: var(--brand); color: var(--text); }
|
|
.nas-list { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
|
.nas-list-empty, .nas-list-loading {
|
|
padding: 32px 20px; text-align: center;
|
|
font-size: 12px; color: var(--text-3);
|
|
}
|
|
.nas-list-loading i { font-size: 18px; display: block; margin-bottom: 8px; animation: nas-spin 1s linear infinite; }
|
|
@keyframes nas-spin { to { transform: rotate(360deg); } }
|
|
.nas-item {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 9px 14px; border-bottom: 1px solid var(--border);
|
|
transition: background .1s;
|
|
}
|
|
.nas-item:last-child { border-bottom: none; }
|
|
.nas-item:hover { background: rgba(255,255,255,.025); }
|
|
.nas-item i { font-size: 14px; flex-shrink: 0; }
|
|
.nas-item-name {
|
|
flex: 1; font-size: 12px; font-family: monospace;
|
|
color: var(--text); background: none; border: none;
|
|
text-align: left; padding: 0; cursor: default;
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
.nas-item-name.is-dir { cursor: pointer; }
|
|
.nas-item-name.is-dir:hover { color: var(--brand); }
|
|
.nas-item-size { font-size: 11px; color: var(--text-3); font-family: monospace; flex-shrink: 0; }
|
|
.nas-item-actions { display: flex; gap: 2px; flex-shrink: 0; opacity: 0; transition: opacity .15s; }
|
|
.nas-item:hover .nas-item-actions { opacity: 1; }
|
|
.nas-item-btn {
|
|
width: 26px; height: 26px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
background: transparent; border: none;
|
|
border-radius: 6px; color: var(--text-2); font-size: 12px;
|
|
cursor: pointer; transition: background .1s, color .1s;
|
|
}
|
|
.nas-item-btn:hover { background: var(--border-light); color: var(--text); }
|
|
.nas-item-btn.danger:hover { background: rgba(248,113,113,.1); color: #f87171; }
|
|
.nas-new-folder { display: flex; gap: 8px; margin-top: 12px; }
|
|
.nas-new-folder .nas-input { flex: 1; height: 36px; }
|
|
.nas-error {
|
|
padding: 10px 14px; border-radius: 8px;
|
|
background: rgba(248,113,113,.08); border: 1px solid rgba(248,113,113,.2);
|
|
color: #fca5a5; font-size: 12px; margin-bottom: 12px;
|
|
}
|
|
|
|
/* ── SMB share select ─────────────────────────────────────────── */
|
|
.nas-select {
|
|
width: 100%; height: 36px; padding: 0 32px 0 12px;
|
|
appearance: none;
|
|
background: var(--bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Cpath d='M19 9l-7 7-7-7'/%3E%3C/svg%3E") no-repeat right 10px center;
|
|
border: 1px solid var(--border-light); border-radius: 8px;
|
|
color: var(--text); font-size: 13px; font-family: monospace;
|
|
outline: none; cursor: pointer; box-sizing: border-box;
|
|
}
|
|
.nas-select:focus { border-color: var(--brand); }
|
|
|
|
/* ── Toast ────────────────────────────────────────────────────── */
|
|
.nas-toast {
|
|
position: fixed; bottom: 24px; right: 24px; z-index: 9999;
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 10px 16px; border-radius: 10px;
|
|
font-size: 12px; font-weight: 500;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,.5); border: 1px solid;
|
|
animation: nas-toast-in .2s ease;
|
|
}
|
|
@keyframes nas-toast-in { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:none; } }
|
|
.nas-toast.success { background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); color: #86efac; }
|
|
.nas-toast.error { background: rgba(248,113,113,.12); border-color: rgba(248,113,113,.25); color: #fca5a5; }
|
|
|
|
/* ── Rename / delete dialog ───────────────────────────────────── */
|
|
.nas-dialog-overlay {
|
|
display: none; position: fixed; inset: 0; z-index: 9500;
|
|
background: rgba(0,0,0,.7); backdrop-filter: blur(4px);
|
|
align-items: center; justify-content: center; padding: 20px;
|
|
}
|
|
.nas-dialog-overlay.open { display: flex; }
|
|
.nas-dialog {
|
|
background: var(--bg-card2, var(--bg));
|
|
border: 1px solid var(--border-light); border-radius: 12px;
|
|
width: 100%; max-width: 400px;
|
|
box-shadow: 0 32px 80px rgba(0,0,0,.7);
|
|
animation: nas-dlg .15s ease;
|
|
}
|
|
@keyframes nas-dlg { from { opacity:0; transform:scale(.96) translateY(-6px); } to { opacity:1; transform:none; } }
|
|
.nas-dialog-header {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 16px 18px 0; font-size: 14px; font-weight: 700; color: var(--text);
|
|
}
|
|
.nas-dialog-header i { color: #f87171; }
|
|
.nas-dialog-body { padding: 10px 18px 16px; font-size: 12px; color: var(--text-2); line-height: 1.6; }
|
|
.nas-dialog-body strong { color: var(--text); font-family: monospace; }
|
|
.nas-dialog-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 0 18px 16px; }
|
|
</style>
|
|
|
|
<div x-data="{ accordionOpen: {{ $hasConnection ? 'false' : 'true' }}, ...nasFmComponent(@js($nodes), @js($connectionsJs)) }"
|
|
class="nas-fm">
|
|
|
|
{{-- ── Accordion header ── --}}
|
|
<button type="button"
|
|
@click="accordionOpen = !accordionOpen"
|
|
class="nas-accordion-btn" :class="accordionOpen && 'open'">
|
|
<div style="display:flex;align-items:center;gap:10px;">
|
|
<div class="nas-accordion-icon"><i class="bi bi-hdd-network"></i></div>
|
|
<div>
|
|
<p class="nas-accordion-title">{{ $title }}</p>
|
|
<p class="nas-accordion-sub" :class="!connections.some(c => c.host) && 'warn'">
|
|
@if($hasConnection)
|
|
Browse and manage files on your NAS
|
|
@else
|
|
<span style="color:#f59e0b;font-weight:600;">Connection not configured</span> — expand to set up
|
|
@endif
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0;">
|
|
@if(!$hasConnection)
|
|
<span class="nas-setup-badge"><i class="bi bi-exclamation-triangle-fill"></i> Setup required</span>
|
|
@endif
|
|
<i class="bi bi-chevron-down nas-accordion-chevron" :class="accordionOpen && 'open'"></i>
|
|
</div>
|
|
</button>
|
|
|
|
{{-- ── Accordion body ── --}}
|
|
<div x-show="accordionOpen"
|
|
x-transition:enter="transition ease-out duration-150"
|
|
x-transition:enter-start="opacity-0 -translate-y-1"
|
|
x-transition:enter-end="opacity-100 translate-y-0"
|
|
x-transition:leave="transition ease-in duration-100"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
style="{{ $hasConnection ? 'display:none' : '' }}">
|
|
|
|
{{-- ── Tab bar ── --}}
|
|
<div class="nas-tabs">
|
|
<button type="button" class="nas-tab" :class="tab === 'schema' && 'active'"
|
|
@click="tab = 'schema'; if(connections.some(c => c.host)) initSchemaTree()">
|
|
<i class="bi bi-diagram-3"></i> Schema
|
|
</button>
|
|
<button type="button" class="nas-tab" :class="tab === 'browser' && 'active'"
|
|
@click="tab = 'browser'; if(items.length === 0 && !loading && !error) load('')">
|
|
<i class="bi bi-folder2-open"></i> Live Browser
|
|
</button>
|
|
<button type="button" class="nas-tab" :class="tab === 'connection' && 'active'"
|
|
@click="tab = 'connection'">
|
|
<i class="bi bi-hdd-network"></i> Connection
|
|
@if(!$hasConnection)<span class="nas-dot"></span>@endif
|
|
</button>
|
|
</div>
|
|
|
|
{{-- ══════════════════════════════════════════════════════════════ --}}
|
|
{{-- SCHEMA TAB --}}
|
|
{{-- ══════════════════════════════════════════════════════════════ --}}
|
|
<div class="nas-panel" :class="tab === 'schema' && 'active'">
|
|
|
|
{{-- Live NAS tree --}}
|
|
<div>
|
|
|
|
{{-- Loading --}}
|
|
<div x-show="schemaLoading" class="nas-list-loading" style="display:none">
|
|
<i class="bi bi-arrow-repeat"></i> Loading…
|
|
</div>
|
|
|
|
{{-- Error --}}
|
|
<div x-show="schemaError && !schemaLoading" class="nas-error" x-text="schemaError" style="display:none"></div>
|
|
|
|
{{-- Live tree --}}
|
|
<div x-show="!schemaLoading && schemaNodes.length > 0" class="nas-schema-tree" style="display:none">
|
|
<template x-for="(node, i) in schemaNodes" :key="node.path + '_' + i">
|
|
<div class="nas-schema-row" :class="node.type === 'dir' ? 'is-dir' : ''"
|
|
@click="toggleSchemaNode(node)">
|
|
<span class="nas-schema-prefix" x-text="node.prefix"></span>
|
|
<svg x-show="node.loading" class="nas-schema-spin" style="display:none;width:12px;height:12px;animation:nas-spin 1s linear infinite;margin-right:6px;flex-shrink:0;"
|
|
fill="none" stroke="#8b949e" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
|
</svg>
|
|
<i x-show="node.type === 'dir' && !node.loading"
|
|
class="bi" :class="node.expanded ? 'bi-folder2-open' : 'bi-folder-fill'"
|
|
style="display:none;color:#f59e0b;font-size:12px;margin-right:6px;flex-shrink:0;"></i>
|
|
<i x-show="node.type !== 'dir'" class="bi bi-file-earmark"
|
|
style="display:none;color:#484f58;font-size:12px;margin-right:6px;flex-shrink:0;"></i>
|
|
<span class="nas-schema-name"
|
|
:class="node.type !== 'dir' ? 'file' : (node.depth === 0 ? 'root' : '')"
|
|
x-text="node.name"></span>
|
|
<span x-show="node.type === 'dir'" class="nas-schema-slash" style="display:none">/</span>
|
|
<span x-show="node.type !== 'dir' && node.size > 0" class="nas-schema-size"
|
|
x-text="formatSize(node.size)" style="display:none"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
{{-- No connection configured --}}
|
|
<div x-show="!schemaLoading && !schemaError && schemaNodes.length === 0 && !connections.some(c => c.host)" style="display:none">
|
|
<p style="font-size:12px;color:var(--text-3);font-style:italic;margin:0;">
|
|
Configure a NAS connection to browse the live folder structure.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{{-- ══════════════════════════════════════════════════════════════ --}}
|
|
{{-- BROWSER TAB --}}
|
|
{{-- ══════════════════════════════════════════════════════════════ --}}
|
|
<div class="nas-panel" :class="tab === 'browser' && 'active'">
|
|
|
|
<div x-show="toast" x-transition class="nas-toast" :class="toast?.type" style="display:none">
|
|
<i class="bi" :class="toast?.type === 'success' ? 'bi-check-circle-fill' : 'bi-x-circle-fill'"></i>
|
|
<span x-text="toast?.msg"></span>
|
|
</div>
|
|
|
|
<div class="nas-breadcrumb">
|
|
<button type="button" class="nas-breadcrumb-btn" @click="load('')">Root</button>
|
|
<template x-for="(seg, i) in segments" :key="i">
|
|
<span style="display:contents;">
|
|
<span class="nas-breadcrumb-sep"><i class="bi bi-chevron-right"></i></span>
|
|
<button type="button" class="nas-breadcrumb-btn"
|
|
@click="load(segments.slice(0, i + 1).join('/'))"
|
|
x-text="seg"></button>
|
|
</span>
|
|
</template>
|
|
<button type="button" class="nas-reload" @click="load(currentPath)" :disabled="loading" title="Refresh">
|
|
<i class="bi bi-arrow-clockwise" :style="loading && 'animation:nas-spin 1s linear infinite'"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div x-show="error && !loading" class="nas-error" x-text="error" style="display:none"></div>
|
|
|
|
<div class="nas-list">
|
|
<div x-show="loading" class="nas-list-loading" style="display:none">
|
|
<i class="bi bi-arrow-repeat"></i> Loading…
|
|
</div>
|
|
<div x-show="!loading && !error && items.length === 0" class="nas-list-empty" style="display:none">
|
|
Empty folder
|
|
</div>
|
|
<template x-for="item in items" :key="item.path">
|
|
<div class="nas-item">
|
|
<i class="bi" :class="item.type === 'dir' ? 'bi-folder-fill' : 'bi-file-earmark'"
|
|
:style="{ color: item.type === 'dir' ? '#f59e0b' : 'var(--text-3)' }"></i>
|
|
<button type="button" class="nas-item-name" :class="item.type === 'dir' && 'is-dir'"
|
|
@click="item.type === 'dir' && load(item.path)"
|
|
x-text="item.name"></button>
|
|
<span x-show="item.type !== 'dir' && item.size > 0" class="nas-item-size"
|
|
x-text="formatSize(item.size)" style="display:none"></span>
|
|
@if($canEdit)
|
|
<div class="nas-item-actions">
|
|
<button type="button" class="nas-item-btn" title="Rename" @click.stop="startRename(item)">
|
|
<i class="bi bi-pencil"></i>
|
|
</button>
|
|
<button type="button" class="nas-item-btn danger" title="Delete" @click.stop="confirmDelete = item">
|
|
<i class="bi bi-trash3"></i>
|
|
</button>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
@if($canEdit)
|
|
<div class="nas-new-folder">
|
|
<input type="text" class="nas-input" x-model="newFolderName"
|
|
placeholder="New folder name…"
|
|
@keydown.enter.prevent="newFolderName.trim() && createFolder()">
|
|
<button type="button" class="adm-btn" @click="createFolder()"
|
|
:disabled="!newFolderName.trim() || creating">
|
|
<i class="bi bi-folder-plus"></i> <span>New Folder</span>
|
|
</button>
|
|
</div>
|
|
@endif
|
|
|
|
</div>
|
|
|
|
{{-- ══════════════════════════════════════════════════════════════ --}}
|
|
{{-- CONNECTION TAB --}}
|
|
{{-- ══════════════════════════════════════════════════════════════ --}}
|
|
<div class="nas-panel" :class="tab === 'connection' && 'active'">
|
|
|
|
@if(!$hasConnection)
|
|
<div class="nas-notice">
|
|
<i class="bi bi-info-circle-fill"></i>
|
|
<div>
|
|
<strong style="color:#fde68a;display:block;margin-bottom:2px;">NAS connection not configured</strong>
|
|
Fill in the fields below, click <strong>Test</strong> to verify, then <strong>Save to DB</strong>.
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- ── Connection cards ── --}}
|
|
<template x-for="(conn, idx) in connections" :key="conn._id">
|
|
<div class="nas-conn-card" :class="!conn.enabled && 'disabled'">
|
|
|
|
{{-- Card header --}}
|
|
<div class="nas-conn-card-header" :class="conn.expanded && 'open'"
|
|
@click="conn.expanded = !conn.expanded">
|
|
<i class="bi bi-chevron-right nas-conn-card-chevron" :class="conn.expanded && 'open'"></i>
|
|
|
|
<input type="text" x-model="conn.name" @click.stop class="nas-conn-name"
|
|
placeholder="Connection name">
|
|
|
|
<div class="nas-conn-badges">
|
|
<span class="nas-proto-badge" x-text="conn.protocol"></span>
|
|
<span class="nas-host-badge" x-text="conn.host || 'no host'"
|
|
:style="!conn.host ? 'font-style:italic' : ''"></span>
|
|
<span x-show="conn.testStatus === 'ok'" class="nas-test-dot ok" style="display:none"></span>
|
|
<span x-show="conn.testStatus === 'fail'" class="nas-test-dot fail" style="display:none"></span>
|
|
<span x-show="conn.testStatus === 'testing'" class="nas-test-dot testing" style="display:none"></span>
|
|
<button type="button" class="nas-toggle" :class="conn.enabled && 'on'"
|
|
@click.stop="conn.enabled = !conn.enabled"
|
|
:title="conn.enabled ? 'Disable' : 'Enable'">
|
|
<span class="nas-toggle-thumb"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Card body --}}
|
|
<div x-show="conn.expanded"
|
|
x-transition:enter="transition ease-out duration-100"
|
|
x-transition:enter-start="opacity-0 -translate-y-1"
|
|
x-transition:enter-end="opacity-100 translate-y-0"
|
|
class="nas-conn-body" style="display:none">
|
|
|
|
{{-- Protocol --}}
|
|
<div class="nas-field">
|
|
<label>Protocol</label>
|
|
<div class="nas-proto-group">
|
|
<template x-for="proto in ['sftp', 'ftp', 'ftps', 'smb']" :key="proto">
|
|
<button type="button" class="nas-proto"
|
|
:class="conn.protocol === proto && 'active'"
|
|
@click="conn.protocol = proto;
|
|
if (proto === 'sftp') conn.port = (conn.port === 21 || conn.port === 445 ? 22 : conn.port);
|
|
if (proto === 'ftp' || proto === 'ftps') conn.port = (conn.port === 22 || conn.port === 445 ? 21 : conn.port);
|
|
if (proto === 'smb') conn.port = (conn.port === 22 || conn.port === 21 ? 445 : conn.port);"
|
|
x-text="proto"></button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="nas-grid">
|
|
<div class="nas-field span2">
|
|
<label>Host <span class="nas-req">*</span></label>
|
|
<input type="text" class="nas-input" x-model="conn.host"
|
|
placeholder="192.168.1.100 or nas.example.com"
|
|
@input="if (!conn.host.trim()) resetConn(conn)">
|
|
</div>
|
|
<div class="nas-field">
|
|
<label>Port</label>
|
|
<input type="number" class="nas-input" x-model.number="conn.port" min="1" max="65535">
|
|
</div>
|
|
<div class="nas-field">
|
|
<label>Username</label>
|
|
<input type="text" class="nas-input" x-model="conn.username" placeholder="admin">
|
|
</div>
|
|
<div class="nas-field span2" x-data="{ showPw: false }">
|
|
<label>Password</label>
|
|
<div class="nas-input-wrap">
|
|
<input :type="showPw ? 'text' : 'password'" class="nas-input"
|
|
x-model="conn.password"
|
|
:placeholder="conn.has_password ? 'Leave blank to use saved password' : 'Enter password'">
|
|
<button type="button" class="nas-eye" @click="showPw = !showPw" tabindex="-1">
|
|
<i class="bi" :class="showPw ? 'bi-eye-slash' : 'bi-eye'"></i>
|
|
</button>
|
|
</div>
|
|
<p x-show="conn.has_password && !conn.password" class="nas-hint ok" style="display:none">
|
|
<i class="bi bi-check-circle-fill"></i> Saved password will be used
|
|
</p>
|
|
</div>
|
|
|
|
{{-- SMB: Share --}}
|
|
<div class="nas-field" x-show="conn.protocol === 'smb'" style="display:none">
|
|
<label>Share <span class="nas-req">*</span></label>
|
|
<div x-show="conn.sharesLoading" class="nas-input"
|
|
style="display:none;display:flex;align-items:center;gap:8px;color:var(--text-3);">
|
|
<i class="bi bi-arrow-repeat" style="animation:nas-spin 1s linear infinite"></i>
|
|
Discovering shares…
|
|
</div>
|
|
<div x-show="!conn.sharesLoading && conn.shares.length > 0" style="display:none">
|
|
<select x-model="conn.share" class="nas-select">
|
|
<option value="">— select a share —</option>
|
|
<template x-for="s in conn.shares" :key="s">
|
|
<option :value="s" x-text="s"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
<input x-show="!conn.sharesLoading && conn.shares.length === 0"
|
|
type="text" x-model="conn.share" placeholder="media"
|
|
class="nas-input">
|
|
</div>
|
|
|
|
{{-- SMB: Domain --}}
|
|
<div class="nas-field" x-show="conn.protocol === 'smb'" style="display:none">
|
|
<label>Domain</label>
|
|
<input type="text" class="nas-input" x-model="conn.smb_domain" placeholder="WORKGROUP">
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Subdirectory + inline picker --}}
|
|
<div class="nas-field" style="margin-bottom:0;">
|
|
<label>Subdirectory</label>
|
|
<div class="nas-path-row">
|
|
<input type="text" class="nas-input" x-model="conn.subdirectory" placeholder="/media">
|
|
<button type="button" class="adm-btn"
|
|
@click="conn.pickerOpen ? (conn.pickerOpen = false) : pickerLoad(conn, '')"
|
|
:disabled="!conn.host.trim()">
|
|
<i class="bi" :class="conn.pickerOpen ? 'bi-x-lg' : 'bi-folder2-open'"></i>
|
|
<span x-text="conn.pickerOpen ? 'Close' : 'Browse'"></span>
|
|
</button>
|
|
</div>
|
|
<p class="nas-hint">Starting directory on the NAS.</p>
|
|
|
|
{{-- Inline picker --}}
|
|
<div x-show="conn.pickerOpen"
|
|
x-transition:enter="transition ease-out duration-150"
|
|
x-transition:enter-start="opacity-0 -translate-y-1"
|
|
x-transition:enter-end="opacity-100 translate-y-0"
|
|
class="nas-picker-wrap" style="display:none">
|
|
<div class="nas-picker-toolbar">
|
|
<div class="nas-picker-breadcrumb">
|
|
<button type="button" class="nas-picker-crumb-btn" @click="pickerLoad(conn, '')">Root</button>
|
|
<template x-for="(seg, si) in conn.pickerSegments" :key="si">
|
|
<span style="display:contents;">
|
|
<span class="nas-picker-sep"><i class="bi bi-chevron-right"></i></span>
|
|
<button type="button" class="nas-picker-crumb-btn"
|
|
@click="pickerLoad(conn, conn.pickerSegments.slice(0, si + 1).join('/'))"
|
|
x-text="seg"></button>
|
|
</span>
|
|
</template>
|
|
</div>
|
|
<button type="button" class="nas-picker-select-btn"
|
|
@click="pickerSelect(conn, conn.pickerPath)">
|
|
<i class="bi bi-check-lg"></i>
|
|
<span x-text="conn.pickerPath ? 'Select /' + conn.pickerPath : 'Select Root'"></span>
|
|
</button>
|
|
<button type="button" class="nas-picker-reload"
|
|
@click="pickerLoad(conn, conn.pickerPath)" :disabled="conn.pickerLoading">
|
|
<i class="bi bi-arrow-clockwise"
|
|
:style="conn.pickerLoading && 'animation:nas-spin 1s linear infinite'"></i>
|
|
</button>
|
|
</div>
|
|
<div class="nas-picker-list">
|
|
<div x-show="conn.pickerLoading" class="nas-list-loading" style="display:none">
|
|
<i class="bi bi-arrow-repeat"></i>
|
|
</div>
|
|
<div x-show="conn.pickerError && !conn.pickerLoading"
|
|
class="nas-picker-error" x-text="conn.pickerError" style="display:none"></div>
|
|
<div x-show="!conn.pickerLoading && !conn.pickerError && conn.pickerItems.length === 0"
|
|
class="nas-picker-empty" style="display:none">No subdirectories found</div>
|
|
<template x-for="pitem in conn.pickerItems" :key="pitem.path">
|
|
<div class="nas-picker-item" @click="pickerLoad(conn, pitem.path)">
|
|
<i class="bi bi-folder-fill" style="color:#f59e0b"></i>
|
|
<span class="nas-picker-item-name" x-text="pitem.name"></span>
|
|
<button type="button" class="nas-picker-item-select"
|
|
@click.stop="pickerSelect(conn, pitem.path)">
|
|
Select
|
|
</button>
|
|
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-3);flex-shrink:0;"></i>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Test status --}}
|
|
<div x-show="conn.testStatus !== null" x-transition
|
|
class="nas-status" :class="conn.testStatus" style="display:none;margin-top:14px;">
|
|
<i class="bi"
|
|
:class="{
|
|
'bi-arrow-repeat': conn.testStatus === 'testing',
|
|
'bi-check-circle-fill': conn.testStatus === 'ok',
|
|
'bi-x-circle-fill': conn.testStatus === 'fail',
|
|
}"
|
|
:style="conn.testStatus === 'testing' && 'animation:nas-spin 1s linear infinite'"></i>
|
|
<span x-text="conn.testStatus === 'testing' ? 'Testing connection…' : conn.testMessage"></span>
|
|
</div>
|
|
|
|
{{-- Save status --}}
|
|
<div x-show="conn.saveStatus !== null" x-transition
|
|
class="nas-status" :class="conn.saveStatus" style="display:none;margin-top:8px;">
|
|
<i class="bi"
|
|
:class="{
|
|
'bi-arrow-repeat': conn.saveStatus === 'saving',
|
|
'bi-check-circle-fill': conn.saveStatus === 'saved',
|
|
'bi-x-circle-fill': conn.saveStatus === 'error',
|
|
}"
|
|
:style="conn.saveStatus === 'saving' && 'animation:nas-spin 1s linear infinite'"></i>
|
|
<span x-text="conn.saveStatus === 'saving' ? 'Saving…' : conn.saveMessage"></span>
|
|
</div>
|
|
|
|
{{-- Card actions --}}
|
|
<div class="nas-card-actions">
|
|
<button type="button" class="nas-remove-btn"
|
|
x-show="connections.length > 1" @click="removeConnection(conn._id)"
|
|
style="display:none">
|
|
<i class="bi bi-trash3"></i> Remove
|
|
</button>
|
|
<div x-show="connections.length <= 1"></div>
|
|
|
|
<div class="nas-card-btn-group">
|
|
{{-- Test button --}}
|
|
<button type="button" class="adm-btn"
|
|
@click="testConn(conn)"
|
|
:disabled="!conn.host.trim() || conn.testStatus === 'testing'">
|
|
<i class="bi"
|
|
:class="conn.testStatus === 'testing' ? 'bi-arrow-repeat' : 'bi-lightning-charge-fill'"
|
|
:style="conn.testStatus === 'testing' && 'animation:nas-spin 1s linear infinite'"></i>
|
|
<span x-text="conn.testStatus === 'testing' ? 'Testing…' : 'Test'"></span>
|
|
</button>
|
|
|
|
{{-- Split save button --}}
|
|
<div style="position:relative;" x-data="{ saveOpen: false }"
|
|
@keydown.escape.window="saveOpen = false">
|
|
<div class="nas-split-btn">
|
|
<button type="button" class="adm-btn adm-btn-primary nas-split-main"
|
|
@click="saveConn(conn, 'database')"
|
|
:disabled="!conn.host.trim() || conn.saveStatus === 'saving'">
|
|
<i class="bi bi-floppy-fill"></i>
|
|
<span x-text="conn.db_id ? 'Update' : 'Save to DB'"></span>
|
|
</button>
|
|
<button type="button" class="adm-btn adm-btn-primary nas-split-chevron"
|
|
@click.stop="saveOpen = !saveOpen">
|
|
<i class="bi bi-chevron-down"
|
|
:style="saveOpen && 'transform:rotate(180deg)'"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div x-show="saveOpen" @click.outside="saveOpen = false"
|
|
x-transition:enter="transition ease-out duration-100"
|
|
x-transition:enter-start="opacity-0 scale-95"
|
|
x-transition:enter-end="opacity-100 scale-100"
|
|
x-transition:leave="transition ease-in duration-75"
|
|
x-transition:leave-start="opacity-100 scale-100"
|
|
x-transition:leave-end="opacity-0 scale-95"
|
|
class="nas-split-dropdown" style="display:none">
|
|
|
|
<div class="nas-split-dropdown-header">Save credentials to</div>
|
|
|
|
<button type="button" class="nas-split-option"
|
|
@click="saveConn(conn, 'database'); saveOpen = false"
|
|
:disabled="!conn.host.trim() || conn.saveStatus === 'saving'">
|
|
<div class="nas-split-option-icon db">
|
|
<i class="bi bi-server"></i>
|
|
</div>
|
|
<div>
|
|
<p class="nas-split-option-title"
|
|
x-text="conn.db_id ? 'Update in Database' : 'Save to Database'"></p>
|
|
<p class="nas-split-option-desc">
|
|
Persists across environments. Supports multiple connections. Password stored encrypted.
|
|
</p>
|
|
</div>
|
|
</button>
|
|
|
|
<hr class="nas-split-divider">
|
|
|
|
<button type="button" class="nas-split-option"
|
|
@click="saveConn(conn, 'env'); saveOpen = false"
|
|
:disabled="!conn.host.trim() || conn.saveStatus === 'saving'">
|
|
<div class="nas-split-option-icon env">
|
|
<i class="bi bi-file-text"></i>
|
|
</div>
|
|
<div>
|
|
<p class="nas-split-option-title">Save to .env</p>
|
|
<p class="nas-split-option-desc">
|
|
Writes <code>NAS_*</code> vars to your .env file. Primary connection only. Page reload required.
|
|
</p>
|
|
</div>
|
|
</button>
|
|
|
|
<p class="nas-split-footer">
|
|
Database is recommended. Use .env only if you manage credentials outside the app.
|
|
</p>
|
|
</div>
|
|
|
|
</div>{{-- /split button --}}
|
|
</div>
|
|
</div>
|
|
|
|
</div>{{-- /card body --}}
|
|
</div>{{-- /card --}}
|
|
</template>
|
|
|
|
{{-- Add connection --}}
|
|
<button type="button" class="nas-add-conn-btn" @click="addConnection()">
|
|
<i class="bi bi-plus-circle"></i> Add Connection
|
|
</button>
|
|
|
|
</div>{{-- /connection tab --}}
|
|
|
|
</div>{{-- /accordion body --}}
|
|
|
|
@if($canEdit)
|
|
{{-- ── Rename dialog ── --}}
|
|
<div class="nas-dialog-overlay" id="nasRenameDialog"
|
|
:class="renaming && 'open'" @keydown.escape.window="renaming = null">
|
|
<div class="nas-dialog">
|
|
<div class="nas-dialog-header"><i class="bi bi-pencil"></i> Rename</div>
|
|
<div class="nas-dialog-body">
|
|
<div class="nas-field" style="margin-bottom:0;">
|
|
<input type="text" class="nas-input" x-model="renameValue"
|
|
x-init="$watch('renaming', v => { if (v) $nextTick(() => { $el.focus(); $el.select(); }) })"
|
|
@keydown.enter="doRename()" @keydown.escape="renaming = null">
|
|
</div>
|
|
</div>
|
|
<div class="nas-dialog-footer">
|
|
<button type="button" class="adm-btn" @click="renaming = null">Cancel</button>
|
|
<button type="button" class="adm-btn adm-btn-primary" @click="doRename()">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ── Delete dialog ── --}}
|
|
<div class="nas-dialog-overlay" id="nasDeleteDialog"
|
|
:class="confirmDelete && 'open'" @keydown.escape.window="confirmDelete = null">
|
|
<div class="nas-dialog">
|
|
<div class="nas-dialog-header"><i class="bi bi-trash3"></i> Delete from NAS</div>
|
|
<div class="nas-dialog-body">
|
|
Delete <strong x-text="confirmDelete?.name"></strong>? This cannot be undone.
|
|
</div>
|
|
<div class="nas-dialog-footer">
|
|
<button type="button" class="adm-btn" @click="confirmDelete = null">Cancel</button>
|
|
<button type="button" class="adm-btn adm-btn-danger" @click="doDelete()" :disabled="deleteLoading">
|
|
<i class="bi" :class="deleteLoading ? 'bi-arrow-repeat' : 'bi-trash3'"
|
|
:style="deleteLoading && 'animation:nas-spin 1s linear infinite'"></i>
|
|
<span x-text="deleteLoading ? 'Deleting…' : 'Delete'"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
</div>{{-- /nas-fm x-data --}}
|
|
|
|
<script>
|
|
function nasFmComponent(nodes, connections) {
|
|
return {
|
|
tab: (connections || []).some(c => c.host) ? 'schema' : 'connection',
|
|
nodes: nodes || [],
|
|
|
|
connections: connections || [],
|
|
nextId: (connections || []).length + 1,
|
|
|
|
init() {
|
|
if (this.connections.some(c => c.host.trim())) {
|
|
this.initSchemaTree();
|
|
}
|
|
this.connections.forEach(conn => {
|
|
if (conn.protocol === 'smb' && conn.host.trim()) {
|
|
const body = {
|
|
protocol: conn.protocol,
|
|
host: conn.host,
|
|
port: conn.port,
|
|
username: conn.username,
|
|
base_path: conn.subdirectory || '/',
|
|
};
|
|
if (conn.password) body.password = conn.password;
|
|
if (conn.smb_domain) body.smb_domain = conn.smb_domain;
|
|
this.fetchShares(conn, body);
|
|
}
|
|
});
|
|
},
|
|
|
|
// Schema tree
|
|
schemaNodes: [],
|
|
schemaLoading: false,
|
|
schemaError: '',
|
|
|
|
// Live browser
|
|
items: [],
|
|
currentPath: '',
|
|
segments: [],
|
|
loading: false,
|
|
error: '',
|
|
toast: null,
|
|
toastTimer: null,
|
|
renaming: null,
|
|
renameValue: '',
|
|
confirmDelete: null,
|
|
deleteLoading: false,
|
|
newFolderName: '',
|
|
creating: false,
|
|
|
|
// ── Connection management ────────────────────────────────────────
|
|
|
|
addConnection() {
|
|
this.connections.push({
|
|
_id: this.nextId++,
|
|
db_id: null,
|
|
name: 'New Connection',
|
|
enabled: true,
|
|
expanded: true,
|
|
protocol: 'sftp',
|
|
host: '',
|
|
port: 22,
|
|
username: '',
|
|
password: '',
|
|
has_password: false,
|
|
share: '',
|
|
smb_domain: '',
|
|
subdirectory: '/media',
|
|
testStatus: null,
|
|
testMessage: '',
|
|
saveStatus: null,
|
|
saveMessage: '',
|
|
pickerOpen: false,
|
|
pickerItems: [],
|
|
pickerPath: '',
|
|
pickerSegments: [],
|
|
pickerLoading: false,
|
|
pickerError: '',
|
|
shares: [],
|
|
sharesLoading: false,
|
|
});
|
|
},
|
|
|
|
removeConnection(id) {
|
|
this.connections = this.connections.filter(c => c._id !== id);
|
|
},
|
|
|
|
resetConn(conn) {
|
|
conn.port = conn.protocol === 'smb' ? 445 : (conn.protocol === 'sftp' ? 22 : 21);
|
|
conn.username = '';
|
|
conn.password = '';
|
|
conn.has_password = false;
|
|
conn.share = '';
|
|
conn.smb_domain = '';
|
|
conn.subdirectory = '';
|
|
conn.testStatus = null;
|
|
conn.testMessage = '';
|
|
conn.saveStatus = null;
|
|
conn.saveMessage = '';
|
|
conn.shares = [];
|
|
conn.sharesLoading = false;
|
|
conn.pickerOpen = false;
|
|
conn.pickerItems = [];
|
|
conn.pickerPath = '';
|
|
conn.pickerSegments = [];
|
|
},
|
|
|
|
// ── Schema live tree ─────────────────────────────────────────────
|
|
|
|
async initSchemaTree() {
|
|
if (this.schemaNodes.length > 0 || this.schemaLoading) return;
|
|
this.schemaLoading = true;
|
|
this.schemaError = '';
|
|
try {
|
|
const r = await fetch('{{ route("nas-fm.list-items") }}', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrfToken() },
|
|
body: JSON.stringify({ path: '' }),
|
|
});
|
|
const d = await r.json();
|
|
if (d.success) {
|
|
this.schemaNodes = (d.items || []).map(item => ({
|
|
...item, depth: 0, prefix: '', expanded: false, loading: false, childrenLoaded: false,
|
|
}));
|
|
this.refreshSchemaPrefixes();
|
|
} else {
|
|
this.schemaError = d.message || 'Failed to load structure.';
|
|
}
|
|
} catch (e) {
|
|
this.schemaError = 'Request error: ' + e.message;
|
|
} finally {
|
|
this.schemaLoading = false;
|
|
}
|
|
},
|
|
|
|
async toggleSchemaNode(node) {
|
|
if (node.type !== 'dir') return;
|
|
const idx = this.schemaNodes.indexOf(node);
|
|
|
|
if (node.expanded) {
|
|
node.expanded = false;
|
|
let end = idx + 1;
|
|
while (end < this.schemaNodes.length && this.schemaNodes[end].depth > node.depth) end++;
|
|
this.schemaNodes.splice(idx + 1, end - idx - 1);
|
|
this.refreshSchemaPrefixes();
|
|
return;
|
|
}
|
|
|
|
node.expanded = true;
|
|
node.loading = true;
|
|
try {
|
|
const r = await fetch('{{ route("nas-fm.list-items") }}', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrfToken() },
|
|
body: JSON.stringify({ path: node.path }),
|
|
});
|
|
const d = await r.json();
|
|
if (d.success) {
|
|
const children = (d.items || []).map(item => ({
|
|
...item, depth: node.depth + 1, prefix: '', expanded: false, loading: false, childrenLoaded: false,
|
|
}));
|
|
this.schemaNodes.splice(idx + 1, 0, ...children);
|
|
node.childrenLoaded = true;
|
|
this.refreshSchemaPrefixes();
|
|
}
|
|
} catch (e) {
|
|
node.expanded = false;
|
|
} finally {
|
|
node.loading = false;
|
|
}
|
|
},
|
|
|
|
refreshSchemaPrefixes() {
|
|
const nodes = this.schemaNodes;
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
const d = nodes[i].depth;
|
|
let prefix = '';
|
|
for (let level = 0; level < d; level++) {
|
|
let hasSibling = false;
|
|
for (let j = i + 1; j < nodes.length; j++) {
|
|
if (nodes[j].depth <= level) { hasSibling = nodes[j].depth === level; break; }
|
|
}
|
|
prefix += hasSibling ? '│ ' : ' ';
|
|
}
|
|
let isLast = true;
|
|
for (let j = i + 1; j < nodes.length; j++) {
|
|
if (nodes[j].depth <= d) { isLast = nodes[j].depth < d; break; }
|
|
}
|
|
prefix += isLast ? '└── ' : '├── ';
|
|
nodes[i].prefix = prefix;
|
|
}
|
|
},
|
|
|
|
// ── Connection test ───────────────────────────────────────────────
|
|
|
|
async testConn(conn) {
|
|
if (!conn.host.trim()) return;
|
|
conn.testStatus = 'testing';
|
|
conn.testMessage = '';
|
|
conn.shares = [];
|
|
const body = {
|
|
protocol: conn.protocol,
|
|
host: conn.host,
|
|
port: conn.port,
|
|
username: conn.username,
|
|
base_path: conn.subdirectory || '/',
|
|
};
|
|
if (conn.password) body.password = conn.password;
|
|
if (conn.share) body.smb_share = conn.share;
|
|
if (conn.smb_domain) body.smb_domain = conn.smb_domain;
|
|
|
|
if (conn.protocol === 'smb' && !conn.share.trim()) {
|
|
conn.testMessage = 'Discovering available shares…';
|
|
await this.fetchShares(conn, body);
|
|
conn.testStatus = conn.shares.length > 0 ? 'ok' : 'fail';
|
|
conn.testMessage = conn.shares.length > 0
|
|
? `Found ${conn.shares.length} share${conn.shares.length === 1 ? '' : 's'} — select one below.`
|
|
: 'No shares found. Check credentials and try again.';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const r = await fetch('{{ route("nas-fm.test") }}', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrfToken() },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const d = await r.json();
|
|
conn.testStatus = d.success ? 'ok' : 'fail';
|
|
conn.testMessage = d.message || (d.success ? 'Connection successful.' : 'Connection failed.');
|
|
if (d.success && conn.protocol === 'smb') this.fetchShares(conn, body);
|
|
} catch (e) {
|
|
conn.testStatus = 'fail';
|
|
conn.testMessage = 'Request error: ' + e.message;
|
|
}
|
|
},
|
|
|
|
async fetchShares(conn, body) {
|
|
conn.sharesLoading = true;
|
|
try {
|
|
const r = await fetch('{{ route("nas-fm.shares") }}', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrfToken() },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const d = await r.json();
|
|
conn.shares = d.shares || [];
|
|
} catch (e) {
|
|
conn.shares = [];
|
|
} finally {
|
|
conn.sharesLoading = false;
|
|
}
|
|
},
|
|
|
|
// ── Save connection ───────────────────────────────────────────────
|
|
|
|
async saveConn(conn, target) {
|
|
if (!conn.host.trim()) return;
|
|
conn.saveStatus = 'saving';
|
|
conn.saveMessage = '';
|
|
const body = {
|
|
save_to: target,
|
|
db_id: conn.db_id,
|
|
name: conn.name,
|
|
enabled: conn.enabled,
|
|
protocol: conn.protocol,
|
|
host: conn.host,
|
|
port: conn.port,
|
|
username: conn.username,
|
|
subdirectory: conn.subdirectory,
|
|
share: conn.share,
|
|
smb_domain: conn.smb_domain,
|
|
};
|
|
if (conn.password) body.password = conn.password;
|
|
try {
|
|
const r = await fetch('{{ route("nas-fm.connections.save") }}', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrfToken() },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const d = await r.json();
|
|
conn.saveStatus = d.success ? 'saved' : 'error';
|
|
conn.saveMessage = d.message || (d.success ? 'Saved.' : 'Save failed.');
|
|
if (d.success && d.id) {
|
|
conn.db_id = d.id;
|
|
conn.has_password = conn.has_password || !!conn.password;
|
|
}
|
|
if (d.success) {
|
|
setTimeout(() => { conn.saveStatus = null; conn.saveMessage = ''; }, 5000);
|
|
if (d.message && d.message.toLowerCase().includes('reload')) {
|
|
setTimeout(() => window.location.reload(), 1400);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
conn.saveStatus = 'error';
|
|
conn.saveMessage = 'Request error: ' + e.message;
|
|
}
|
|
},
|
|
|
|
// ── Subdirectory picker ───────────────────────────────────────────
|
|
|
|
async pickerLoad(conn, path) {
|
|
conn.pickerOpen = true;
|
|
conn.pickerLoading = true;
|
|
conn.pickerError = '';
|
|
conn.pickerPath = path;
|
|
conn.pickerSegments = path ? path.split('/').filter(Boolean) : [];
|
|
|
|
const body = {
|
|
path: path,
|
|
base_path: '/',
|
|
protocol: conn.protocol,
|
|
host: conn.host,
|
|
port: conn.port,
|
|
username: conn.username,
|
|
};
|
|
if (conn.password) body.password = conn.password;
|
|
if (conn.share) body.smb_share = conn.share;
|
|
if (conn.smb_domain) body.smb_domain = conn.smb_domain;
|
|
|
|
try {
|
|
const r = await fetch('{{ route("nas-fm.list-items") }}', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrfToken() },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const d = await r.json();
|
|
conn.pickerLoading = false;
|
|
if (d.success) {
|
|
conn.pickerItems = (d.items || []).filter(i => i.type === 'dir');
|
|
} else {
|
|
conn.pickerError = d.message || 'Failed to load.';
|
|
conn.pickerItems = [];
|
|
}
|
|
} catch (e) {
|
|
conn.pickerLoading = false;
|
|
conn.pickerError = 'Request error: ' + e.message;
|
|
}
|
|
},
|
|
|
|
pickerSelect(conn, path) {
|
|
conn.subdirectory = path ? ('/' + path.replace(/^\/+/, '')) : '/';
|
|
conn.pickerOpen = false;
|
|
conn.pickerItems = [];
|
|
conn.pickerPath = '';
|
|
conn.pickerSegments = [];
|
|
},
|
|
|
|
// ── Live browser ──────────────────────────────────────────────────
|
|
|
|
async load(path) {
|
|
this.loading = true;
|
|
this.error = '';
|
|
this.currentPath = path;
|
|
this.segments = path ? path.split('/').filter(Boolean) : [];
|
|
const r = await fetch('{{ route("nas-fm.list-items") }}', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrfToken() },
|
|
body: JSON.stringify({ path }),
|
|
});
|
|
const d = await r.json();
|
|
this.loading = false;
|
|
if (d.success) {
|
|
this.items = d.items || [];
|
|
} else {
|
|
this.error = d.message || 'Failed to load directory.';
|
|
this.items = [];
|
|
}
|
|
},
|
|
|
|
startRename(item) {
|
|
this.renaming = item;
|
|
this.renameValue = item.name;
|
|
},
|
|
|
|
async doRename() {
|
|
if (!this.renaming || !this.renameValue.trim()) return;
|
|
const r = await fetch('{{ route("nas-fm.rename") }}', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrfToken() },
|
|
body: JSON.stringify({ path: this.renaming.path, name: this.renameValue.trim() }),
|
|
});
|
|
const d = await r.json();
|
|
if (d.success) {
|
|
this.notify('Renamed to ' + this.renameValue.trim(), 'success');
|
|
this.renaming = null;
|
|
this.load(this.currentPath);
|
|
} else {
|
|
this.notify(d.message, 'error');
|
|
}
|
|
},
|
|
|
|
async doDelete() {
|
|
if (!this.confirmDelete) return;
|
|
this.deleteLoading = true;
|
|
const r = await fetch('{{ route("admin.nas.delete") }}', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrfToken() },
|
|
body: JSON.stringify({ path: this.confirmDelete.path, type: this.confirmDelete.type }),
|
|
});
|
|
const d = await r.json();
|
|
this.deleteLoading = false;
|
|
if (d.success) {
|
|
this.notify('Deleted from NAS', 'success');
|
|
this.confirmDelete = null;
|
|
this.load(this.currentPath);
|
|
} else {
|
|
this.notify(d.message, 'error');
|
|
}
|
|
},
|
|
|
|
async createFolder() {
|
|
const name = this.newFolderName.trim();
|
|
if (!name) return;
|
|
this.creating = true;
|
|
const path = this.currentPath ? this.currentPath + '/' + name : name;
|
|
const r = await fetch('{{ route("nas-fm.create") }}', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrfToken() },
|
|
body: JSON.stringify({ path, type: 'dir' }),
|
|
});
|
|
const d = await r.json();
|
|
this.creating = false;
|
|
if (d.success) {
|
|
this.newFolderName = '';
|
|
this.notify('Folder created', 'success');
|
|
this.load(this.currentPath);
|
|
} else {
|
|
this.notify(d.message, 'error');
|
|
}
|
|
},
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────
|
|
|
|
formatSize(bytes) {
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
|
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
return (bytes / 1073741824).toFixed(2) + ' GB';
|
|
},
|
|
|
|
notify(msg, type) {
|
|
clearTimeout(this.toastTimer);
|
|
this.toast = { msg, type };
|
|
this.toastTimer = setTimeout(() => this.toast = null, 4000);
|
|
},
|
|
|
|
csrfToken() {
|
|
return document.querySelector('meta[name="csrf-token"]')?.content ?? '';
|
|
},
|
|
};
|
|
}
|
|
</script>
|