Redesign NAS file manager to match admin dark theme
- Published package views to resources/views/vendor/nas-file-manager/ - Rewrote file-manager.blade.php using admin CSS variables (--bg, --bg-card, --border, --text, --brand, etc.) and Bootstrap Icons instead of Tailwind/SVGs - Replaced accordion wrapper with flat tab bar matching .adm-* tab pattern - Dialogs use --bg-card2, --border-light, and .adm-btn classes - Removed Tailwind CDN and brand color overrides from nas-storage.blade.php Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8a00bcecac
commit
615e7efd7c
@ -2,12 +2,6 @@
|
||||
|
||||
@section('title', 'NAS Storage')
|
||||
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
.nas-wrapper * { box-sizing: border-box; }
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="adm-page-header">
|
||||
<h1 class="adm-page-title">
|
||||
@ -15,7 +9,7 @@
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="nas-wrapper">
|
||||
<div class="adm-card">
|
||||
@include('nas-file-manager::file-manager', [
|
||||
'nodes' => $nodes,
|
||||
'canEdit' => true,
|
||||
@ -26,22 +20,4 @@
|
||||
|
||||
@section('scripts')
|
||||
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: 'rgba(230,30,30,.08)',
|
||||
500: '#e61e1e',
|
||||
600: '#e61e1e',
|
||||
700: '#c91a1a',
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
corePlugins: { preflight: false }
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
|
||||
703
resources/views/vendor/nas-file-manager/file-manager.blade.php
vendored
Normal file
703
resources/views/vendor/nas-file-manager/file-manager.blade.php
vendored
Normal file
@ -0,0 +1,703 @@
|
||||
@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';
|
||||
|
||||
$conn = config('nas-file-manager.connection', []);
|
||||
$hasConnection = !empty($conn['host']);
|
||||
$connConfig = [
|
||||
'protocol' => $conn['protocol'] ?? 'sftp',
|
||||
'host' => $conn['host'] ?? '',
|
||||
'port' => (int) ($conn['port'] ?? 22),
|
||||
'username' => $conn['username'] ?? '',
|
||||
'path' => $conn['path'] ?? '/media',
|
||||
'smb_share' => $conn['smb_share'] ?? '',
|
||||
'smb_domain' => $conn['smb_domain'] ?? '',
|
||||
'has_password' => !empty($conn['password']),
|
||||
];
|
||||
@endphp
|
||||
|
||||
<style>
|
||||
.nas-fm { color: var(--text); }
|
||||
|
||||
/* ── 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: 7px;
|
||||
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 banner ──────────────────────────────────────── */
|
||||
.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 i { font-size: 14px; flex-shrink: 0; }
|
||||
|
||||
/* ── Notice banner ──────────────────────────────────────── */
|
||||
.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.error { border-color: rgba(248,113,113,.5); background: rgba(248,113,113,.04); }
|
||||
.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; }
|
||||
|
||||
/* ── Actions row ─────────────────────────────────────────── */
|
||||
.nas-actions-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; flex-wrap: wrap;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 16px;
|
||||
}
|
||||
.nas-env-hint { font-size: 11px; color: var(--text-3); line-height: 1.6; }
|
||||
.nas-env-hint code {
|
||||
font-family: monospace; font-size: 10px;
|
||||
background: var(--bg-card2); padding: 1px 5px;
|
||||
border-radius: 4px; color: var(--text-2);
|
||||
}
|
||||
|
||||
/* ── Schema tree ─────────────────────────────────────────── */
|
||||
.nas-schema-tree {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 14px;
|
||||
font-family: monospace; font-size: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.nas-schema-hint { font-size: 12px; color: var(--text-2); margin-bottom: 12px; }
|
||||
.nas-schema-hint span { color: var(--brand); }
|
||||
|
||||
/* ── 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;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.nas-breadcrumb-btn:hover { opacity: .75; }
|
||||
.nas-breadcrumb-sep { color: var(--text-3); font-size: 11px; }
|
||||
.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;
|
||||
transition: color .1s;
|
||||
}
|
||||
.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; }
|
||||
|
||||
/* ── New folder row ──────────────────────────────────────── */
|
||||
.nas-new-folder { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.nas-new-folder .nas-input { flex: 1; height: 36px; }
|
||||
|
||||
/* ── Error line ──────────────────────────────────────────── */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ── Dialog overlay ──────────────────────────────────────── */
|
||||
.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);
|
||||
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; }
|
||||
|
||||
/* ── 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; }
|
||||
</style>
|
||||
|
||||
<div x-data="nasFm(@js($nodes), @js($connConfig))" class="nas-fm">
|
||||
|
||||
{{-- ── Tab bar ── --}}
|
||||
<div class="nas-tabs">
|
||||
<button type="button" class="nas-tab" :class="tab === 'schema' && 'active'" @click="tab = 'schema'">
|
||||
<i class="bi bi-diagram-3"></i> Schema
|
||||
</button>
|
||||
<button type="button" class="nas-tab" :class="tab === 'browser' && 'active'"
|
||||
@click="tab = 'browser'; if(!browsed) { browsed = true; 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'">
|
||||
<p class="nas-schema-hint">
|
||||
Paths are relative to the configured base path.
|
||||
<span>Placeholders in {curly braces}</span> are filled in at runtime.
|
||||
</p>
|
||||
<div class="nas-schema-tree">
|
||||
<template x-for="(node, i) in nodes" :key="node.id ?? i">
|
||||
<div class="nas-item" style="border:none;padding:4px 0;" :style="{ paddingLeft: (node.depth * 16) + 'px' }">
|
||||
<i class="bi" :class="node.is_template ? 'bi-folder2' : (node.depth === 0 ? 'bi-hdd' : 'bi-folder-fill')"
|
||||
:style="{ color: node.is_template ? 'var(--text-3)' : (node.depth === 0 ? 'var(--brand)' : (node.depth <= 2 ? '#f59e0b' : '#fbbf24')) }"></i>
|
||||
<span :style="{
|
||||
fontFamily: 'monospace', fontSize: '12px',
|
||||
fontStyle: node.is_template ? 'italic' : 'normal',
|
||||
color: node.is_template ? 'var(--text-3)' : (node.depth === 0 ? 'var(--text)' : 'var(--text-2)'),
|
||||
fontWeight: node.depth <= 1 ? '600' : '400'
|
||||
}"
|
||||
x-text="node.label + (node.is_template ? '' : '/')"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="nodes.length === 0">
|
||||
<p style="color:var(--text-3);font-size:12px;font-style:italic;margin:0;">
|
||||
No schema defined. Add nodes in <code style="font-family:monospace;background:var(--border);padding:1px 4px;border-radius:3px;">config/nas-file-manager.php</code> under the <code style="font-family:monospace;background:var(--border);padding:1px 4px;border-radius:3px;">schema</code> key.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── BROWSER TAB ── --}}
|
||||
<div class="nas-panel" :class="tab === 'browser' && 'active'">
|
||||
|
||||
{{-- Toast --}}
|
||||
<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>
|
||||
|
||||
{{-- Breadcrumb --}}
|
||||
<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>
|
||||
|
||||
{{-- Error --}}
|
||||
<div x-show="browserError && !loading" class="nas-error" x-text="browserError" style="display:none"></div>
|
||||
|
||||
{{-- File list --}}
|
||||
<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 && !browserError && 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 and click <strong>Test Connection</strong> to verify, then persist the values in your <code>.env</code> file.
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Status --}}
|
||||
<div x-show="connStatus !== null" x-transition class="nas-status" :class="connStatus" style="display:none">
|
||||
<i class="bi" :class="{
|
||||
'bi-arrow-repeat': connStatus === 'testing',
|
||||
'bi-check-circle-fill': connStatus === 'ok',
|
||||
'bi-x-circle-fill': connStatus === 'fail'
|
||||
}" :style="connStatus === 'testing' && 'animation:nas-spin 1s linear infinite'"></i>
|
||||
<span x-text="connStatus === 'testing' ? 'Testing connection…' : connMessage"></span>
|
||||
</div>
|
||||
|
||||
{{-- 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="connProtocol === proto && 'active'"
|
||||
@click="connProtocol = proto;
|
||||
if(proto === 'sftp') connPort = (connPort === 21 || connPort === 445 ? 22 : connPort);
|
||||
else if(proto === 'ftp' || proto === 'ftps') connPort = (connPort === 22 || connPort === 445 ? 21 : connPort);
|
||||
else if(proto === 'smb') connPort = (connPort === 22 || connPort === 21 ? 445 : connPort);"
|
||||
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="connHost"
|
||||
placeholder="192.168.1.100 or nas.example.com"
|
||||
:class="connHost.trim() === '' && connStatus !== null && 'error'">
|
||||
<p x-show="connHost.trim() === '' && connStatus !== null"
|
||||
class="nas-hint" style="color:#f87171;display:none">Host is required.</p>
|
||||
</div>
|
||||
|
||||
<div class="nas-field">
|
||||
<label>Port</label>
|
||||
<input type="number" class="nas-input" x-model.number="connPort" min="1" max="65535">
|
||||
</div>
|
||||
|
||||
<div class="nas-field">
|
||||
<label>Username</label>
|
||||
<input type="text" class="nas-input" x-model="connUsername" 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="connPassword"
|
||||
:placeholder="connHasSavedPassword ? '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="connHasSavedPassword && !connPassword" class="nas-hint ok" style="display:none">
|
||||
<i class="bi bi-check-circle-fill"></i> Saved password will be used
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="nas-field span2">
|
||||
<label>Base Path</label>
|
||||
<input type="text" class="nas-input" x-model="connPath" placeholder="/media">
|
||||
<p class="nas-hint">Remote directory the file browser starts from.</p>
|
||||
</div>
|
||||
|
||||
<div class="nas-field" x-show="connProtocol === 'smb'" style="display:none">
|
||||
<label>SMB Share</label>
|
||||
<input type="text" class="nas-input" x-model="connSmbShare" placeholder="MediaShare">
|
||||
</div>
|
||||
|
||||
<div class="nas-field" x-show="connProtocol === 'smb'" style="display:none">
|
||||
<label>SMB Domain</label>
|
||||
<input type="text" class="nas-input" x-model="connSmbDomain" placeholder="WORKGROUP">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nas-actions-row">
|
||||
<p class="nas-env-hint">
|
||||
Persist in <code>.env</code>:
|
||||
<code>NAS_HOST</code> <code>NAS_USERNAME</code> <code>NAS_PASSWORD</code>
|
||||
</p>
|
||||
<button type="button" class="adm-btn adm-btn-primary" @click="testConn()"
|
||||
:disabled="!connHost.trim() || connStatus === 'testing'">
|
||||
<i class="bi" :class="connStatus === 'testing' ? 'bi-arrow-repeat' : 'bi-lightning-charge-fill'"
|
||||
:style="connStatus === 'testing' && 'animation:nas-spin 1s linear infinite'"></i>
|
||||
<span x-text="connStatus === 'testing' ? 'Testing…' : 'Test Connection'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{-- ── Rename dialog ── --}}
|
||||
@if($canEdit)
|
||||
<div class="nas-dialog-overlay" id="nasRenameDialog" @keydown.escape.window="closeRename()">
|
||||
<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" id="nasRenameInput"
|
||||
@keydown.enter="doRename()" @keydown.escape="closeRename()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="nas-dialog-footer">
|
||||
<button type="button" class="adm-btn" @click="closeRename()">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" @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
|
||||
|
||||
<script>
|
||||
function nasFm(nodes, connConfig) {
|
||||
return {
|
||||
tab: connConfig?.host ? 'schema' : 'connection',
|
||||
nodes: nodes || [],
|
||||
browsed: false,
|
||||
|
||||
items: [],
|
||||
currentPath: '',
|
||||
segments: [],
|
||||
loading: false,
|
||||
browserError: '',
|
||||
|
||||
toast: null,
|
||||
toastTimer: null,
|
||||
|
||||
renaming: null,
|
||||
renameValue: '',
|
||||
confirmDelete: null,
|
||||
deleteLoading: false,
|
||||
newFolderName: '',
|
||||
creating: false,
|
||||
|
||||
connProtocol: connConfig?.protocol || 'sftp',
|
||||
connHost: connConfig?.host || '',
|
||||
connPort: connConfig?.port || 22,
|
||||
connUsername: connConfig?.username || '',
|
||||
connPassword: '',
|
||||
connPath: connConfig?.path || '/media',
|
||||
connSmbShare: connConfig?.smb_share || '',
|
||||
connSmbDomain: connConfig?.smb_domain || '',
|
||||
connHasSavedPassword: connConfig?.has_password || false,
|
||||
connStatus: null,
|
||||
connMessage: '',
|
||||
|
||||
init() {
|
||||
this.$watch('confirmDelete', v => {
|
||||
document.getElementById('nasDeleteDialog')?.classList.toggle('open', !!v);
|
||||
});
|
||||
this.$watch('renaming', v => {
|
||||
const dlg = document.getElementById('nasRenameDialog');
|
||||
if (!dlg) return;
|
||||
dlg.classList.toggle('open', !!v);
|
||||
if (v) this.$nextTick(() => {
|
||||
const inp = document.getElementById('nasRenameInput');
|
||||
if (inp) { inp.value = v.name; inp.focus(); inp.select(); }
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async load(path) {
|
||||
this.loading = true; this.browserError = '';
|
||||
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.csrf() },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
const d = await r.json();
|
||||
this.loading = false;
|
||||
if (d.success) { this.items = d.items || []; }
|
||||
else { this.browserError = d.message || 'Failed to load directory.'; this.items = []; }
|
||||
},
|
||||
|
||||
startRename(item) { this.renaming = item; },
|
||||
closeRename() { this.renaming = null; },
|
||||
|
||||
async doRename() {
|
||||
if (!this.renaming) return;
|
||||
const name = document.getElementById('nasRenameInput')?.value?.trim();
|
||||
if (!name) return;
|
||||
const r = await fetch('{{ route("nas-fm.rename") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrf() },
|
||||
body: JSON.stringify({ path: this.renaming.path, name }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) { this.notify('Renamed to ' + name, '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("nas-fm.delete") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrf() },
|
||||
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', '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.csrf() },
|
||||
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'); }
|
||||
},
|
||||
|
||||
async testConn() {
|
||||
if (!this.connHost.trim()) return;
|
||||
this.connStatus = 'testing'; this.connMessage = '';
|
||||
const body = { protocol: this.connProtocol, host: this.connHost, port: this.connPort, username: this.connUsername, path: this.connPath };
|
||||
if (this.connPassword) body.password = this.connPassword;
|
||||
if (this.connSmbShare) body.smb_share = this.connSmbShare;
|
||||
if (this.connSmbDomain) body.smb_domain = this.connSmbDomain;
|
||||
try {
|
||||
const r = await fetch('{{ route("nas-fm.test") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrf() },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const d = await r.json();
|
||||
this.connStatus = d.success ? 'ok' : 'fail';
|
||||
this.connMessage = d.message || (d.success ? 'Connection successful.' : 'Connection failed.');
|
||||
} catch (e) {
|
||||
this.connStatus = 'fail'; this.connMessage = 'Request error: ' + e.message;
|
||||
}
|
||||
},
|
||||
|
||||
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);
|
||||
},
|
||||
|
||||
csrf() { return document.querySelector('meta[name="csrf-token"]')?.content ?? ''; },
|
||||
};
|
||||
}
|
||||
</script>
|
||||
Loading…
x
Reference in New Issue
Block a user