Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ RP_ORIGIN=http://localhost:4321

# Upload staging directory (default: ~/.mininas/data/uploads)
# UPLOAD_STAGING_DIR=~/.mininas/data/uploads

# Custom display name shown in the UI header and page titles (default: MiniNAS)
# APP_NAME=MiniNAS
1 change: 1 addition & 0 deletions packages/api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,5 @@ export const config = {
webDistDir: process.env.WEB_DIST_DIR || '',
basePath: normalizeBasePath(process.env.BASE_PATH || ''),
version: process.env.MININAS_VERSION || 'dev',
appName: process.env.APP_NAME || 'MiniNAS',
} as const
9 changes: 7 additions & 2 deletions packages/api/src/middleware/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,15 @@ export function createStaticMiddleware(distDir: string) {
const raw = fs.readFileSync(filePath)
const cacheControl = ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable'

// For HTML files: inject __BASE_PATH__ and rewrite asset references
// For HTML files: inject __BASE_PATH__ / __APP_NAME__ and rewrite asset references
if (ext === '.html') {
let html = raw.toString('utf-8')
html = html.replace('<head>', `<head><script>window.__BASE_PATH__="${bp}";</script>`)
const appName = config.appName
html = html.replace(
'<head>',
`<head><script>window.__BASE_PATH__="${bp}";window.__APP_NAME__=${JSON.stringify(appName)};</script>`,
)
html = html.replace(/<title>MiniNAS(.*?)<\/title>/g, `<title>${appName}$1</title>`)
if (bp) {
// Rewrite Astro-generated asset references
html = html.replace(/href="\//g, `href="${bp}/`)
Expand Down
27 changes: 26 additions & 1 deletion packages/web/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,34 @@
import fs from 'node:fs'
import http from 'node:http'
import os from 'node:os'
import path from 'node:path'
import react from '@astrojs/react'
import tailwind from '@astrojs/tailwind'
import { defineConfig } from 'astro/config'

function loadAppName() {
// Read from ~/.mininas/config.json, then .env (same priority as API config)
let name = 'MiniNAS'
try {
const cfg = JSON.parse(
fs.readFileSync(path.join(os.homedir(), '.mininas', 'config.json'), 'utf-8'),
)
if (cfg.APP_NAME) name = cfg.APP_NAME
} catch {}
try {
const env = fs.readFileSync(path.resolve(import.meta.dirname, '../../.env'), 'utf-8')
const match = env.match(/^APP_NAME=(.+)$/m)
if (match) name = match[1].trim()
} catch {}
// Real env var wins
if (process.env.APP_NAME) name = process.env.APP_NAME
return name
}

// Rewrite /volumes/X/Y/Z to /volumes so the catch-all [...path].astro
// page is served for all sub-paths (used in both dev and preview).
function volumesFallback(req, _res, next) {
if (req.url && req.url.startsWith('/volumes/') && !req.url.includes('.')) {
if (req.url?.startsWith('/volumes/') && !req.url.includes('.')) {
req.url = '/volumes'
}
next()
Expand All @@ -17,6 +39,9 @@ export default defineConfig({
integrations: [react(), tailwind()],
server: { port: 4321, host: '0.0.0.0' },
vite: {
define: {
__APP_NAME_DEFAULT__: JSON.stringify(loadAppName()),
},
plugins: [
{
name: 'volumes-spa-fallback',
Expand Down
3 changes: 2 additions & 1 deletion packages/web/src/components/AdminPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
} from '../hooks/useAdmin'
import { useAuth } from '../hooks/useAuth'
import type { AdminVolume } from '../lib/api'
import { APP_NAME } from '../lib/appName'
import { withBase } from '../lib/basePath'
import UpdateSection from './UpdateSection'
import Badge from './ui/Badge'
Expand Down Expand Up @@ -362,7 +363,7 @@ function VolumesSection() {
const handleRemove = (vol: AdminVolume) => {
if (
confirm(
`Remove volume "${vol.label}" (${vol.id})? This only removes it from MiniNAS — files on disk are not deleted.`,
`Remove volume "${vol.label}" (${vol.id})? This only removes it from ${APP_NAME} — files on disk are not deleted.`,
)
) {
removeMutation.mutate(vol.id)
Expand Down
261 changes: 148 additions & 113 deletions packages/web/src/components/FileBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useToast } from '../hooks/useToast'
import { useUpload } from '../hooks/useUpload'
import type { FileEntry } from '../lib/api'
import { api } from '../lib/api'
import { APP_NAME } from '../lib/appName'
import { BASE_PATH, withBase } from '../lib/basePath'
import { getFilesFromDataTransfer } from '../lib/drop'
import Breadcrumbs from './Breadcrumbs'
Expand Down Expand Up @@ -102,11 +103,27 @@ function FileBrowserInner() {
const {
selected,
toggle: toggleSelection,
selectRange,
selectAll,
clear: clearSelection,
count: selectionCount,
lastToggled,
} = useSelection()

const fileAreaRef = useRef<HTMLDivElement>(null)

useEffect(() => {
if (selectionCount === 0) return
const onClick = (e: MouseEvent) => {
if (fileAreaRef.current?.contains(e.target as Node)) return
// Don't clear if clicking inside the bulk action bar
if ((e.target as HTMLElement).closest('[data-bulk-actions]')) return
clearSelection()
}
window.addEventListener('click', onClick)
return () => window.removeEventListener('click', onClick)
}, [selectionCount, clearSelection])

// Global drag-and-drop: show overlay when files are dragged anywhere on page
const [globalDragging, setGlobalDragging] = useState(false)
const dragCounter = useRef(0)
Expand Down Expand Up @@ -245,13 +262,16 @@ function FileBrowserInner() {
}

return (
<div className="max-w-6xl mx-auto px-4 py-6">
<div className={`max-w-6xl mx-auto px-4 py-6 ${selectionCount > 0 ? 'pb-20' : ''}`}>
{/* Header */}
<div className="flex items-center justify-between gap-3 mb-6">
<div className="flex items-center gap-2.5 shrink-0">
<img src="/logo.png" alt="MiniNAS" className="w-8 h-8" />
<h1 className="text-xl font-semibold text-gray-900 hidden sm:block">MiniNAS</h1>
</div>
<a
href={withBase('/')}
className="flex items-center gap-2.5 shrink-0 hover:opacity-80 transition-opacity"
>
<img src="/logo.png" alt={APP_NAME} className="w-8 h-8" />
<h1 className="text-xl font-semibold text-gray-900 hidden sm:block">{APP_NAME}</h1>
</a>
<div className="flex items-center gap-2 sm:gap-4 min-w-0">
<VolumeSelector selectedVolume={volume} onSelect={handleVolumeSelect} />
{user?.role === 'admin' && (
Expand Down Expand Up @@ -365,121 +385,136 @@ function FileBrowserInner() {
</div>
)}

{/* Bulk Action Bar */}
{selectionCount > 0 && (
<div className="flex items-center gap-3 mb-4 px-4 py-2.5 bg-brand-50 border border-brand-200 rounded-lg">
<span className="text-sm font-medium text-brand-700">{selectionCount} selected</span>
<div className="flex items-center gap-2 ml-auto">
<button
type="button"
onClick={async () => {
const paths = Array.from(selected)
try {
const res = await fetch(`${withBase('/api/v1')}/download/zip`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ volume, paths }),
})
if (!res.ok) throw new Error('Download failed')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'download.zip'
a.click()
URL.revokeObjectURL(url)
} catch (err) {
addToast('error', `Download failed: ${(err as Error).message}`)
}
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md bg-white border border-gray-200 hover:bg-gray-50 text-gray-700 shadow-sm transition-colors"
>
<Download size={16} />
Download
</button>
<button
type="button"
onClick={async () => {
if (!confirm(`Delete ${selectionCount} items?`)) return
const paths = Array.from(selected)
let failed = 0
for (const path of paths) {
{/* Bulk Action Bar - fixed bottom */}
<div
className={`fixed bottom-0 left-0 right-0 z-40 transition-transform duration-200 ease-out ${selectionCount > 0 ? 'translate-y-0' : 'translate-y-full'}`}
>
<div
data-bulk-actions
className="bg-white/95 backdrop-blur-sm border-t border-gray-200 shadow-[0_-2px_10px_rgba(0,0,0,0.08)]"
>
<div className="max-w-6xl mx-auto px-4 py-3 flex items-center gap-3">
<span className="text-sm font-medium text-brand-700 whitespace-nowrap">
{selectionCount} selected
</span>
<div className="flex items-center gap-2 ml-auto">
<button
type="button"
onClick={async () => {
const paths = Array.from(selected)
try {
await api.deleteFile(volume, path)
} catch {
failed++
const res = await fetch(`${withBase('/api/v1')}/download/zip`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ volume, paths }),
})
if (!res.ok) throw new Error('Download failed')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'download.zip'
a.click()
URL.revokeObjectURL(url)
} catch (err) {
addToast('error', `Download failed: ${(err as Error).message}`)
}
}
clearSelection()
refetch()
if (failed > 0) {
addToast('error', `Failed to delete ${failed} items`)
} else {
addToast('success', `Deleted ${paths.length} items`)
}
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md bg-red-600 hover:bg-red-700 text-white transition-colors"
>
<Trash2 size={16} />
Delete
</button>
<button
type="button"
onClick={clearSelection}
className="p-1.5 rounded-md hover:bg-brand-100 text-brand-500 transition-colors"
title="Clear selection"
>
<X size={16} />
</button>
}}
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-md bg-white border border-gray-200 hover:bg-gray-50 text-gray-700 shadow-sm transition-colors"
>
<Download size={16} />
<span className="hidden sm:inline">Download</span>
</button>
<button
type="button"
onClick={async () => {
if (!confirm(`Delete ${selectionCount} items?`)) return
const paths = Array.from(selected)
let failed = 0
for (const path of paths) {
try {
await api.deleteFile(volume, path)
} catch {
failed++
}
}
clearSelection()
refetch()
if (failed > 0) {
addToast('error', `Failed to delete ${failed} items`)
} else {
addToast('success', `Deleted ${paths.length} items`)
}
}}
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-md bg-red-600 hover:bg-red-700 text-white transition-colors"
>
<Trash2 size={16} />
<span className="hidden sm:inline">Delete</span>
</button>
<button
type="button"
onClick={clearSelection}
className="p-2 rounded-md hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors"
title="Clear selection"
>
<X size={16} />
</button>
</div>
</div>
</div>
)}
</div>

{/* Content */}
{!volume ? (
<EmptyState icon={HardDrive} title="Select a volume to get started" />
) : isLoading ? (
viewMode === 'list' ? (
<FileListSkeleton />
<div ref={fileAreaRef}>
{!volume ? (
<EmptyState icon={HardDrive} title="Select a volume to get started" />
) : isLoading ? (
viewMode === 'list' ? (
<FileListSkeleton />
) : (
<FileGridSkeleton />
)
) : error ? (
<div className="text-center py-20 text-red-500">
Error loading files: {(error as Error).message}
</div>
) : viewMode === 'list' ? (
<FileList
entries={data?.entries || []}
volume={volume}
onNavigate={navigateTo}
onDelete={(path) =>
deleteMutation.mutate(path, {
onSuccess: () => addToast('success', 'File deleted'),
onError: (err) => addToast('error', `Delete failed: ${(err as Error).message}`),
})
}
onPreview={setPreviewFile}
onShare={setShareFile}
onContextMenu={handleContextMenu}
selectable
selected={selected}
onToggle={toggleSelection}
onShiftSelect={selectRange}
lastToggled={lastToggled}
onSelectAll={(paths) => (paths.length > 0 ? selectAll(paths) : clearSelection())}
/>
) : (
<FileGridSkeleton />
)
) : error ? (
<div className="text-center py-20 text-red-500">
Error loading files: {(error as Error).message}
</div>
) : viewMode === 'list' ? (
<FileList
entries={data?.entries || []}
volume={volume}
onNavigate={navigateTo}
onDelete={(path) =>
deleteMutation.mutate(path, {
onSuccess: () => addToast('success', 'File deleted'),
onError: (err) => addToast('error', `Delete failed: ${(err as Error).message}`),
})
}
onPreview={setPreviewFile}
onShare={setShareFile}
onContextMenu={handleContextMenu}
selectable
selected={selected}
onToggle={toggleSelection}
onSelectAll={(paths) => (paths.length > 0 ? selectAll(paths) : clearSelection())}
/>
) : (
<FileGrid
entries={data?.entries || []}
volume={volume}
onNavigate={navigateTo}
onPreview={setPreviewFile}
onContextMenu={handleContextMenu}
selectable
selected={selected}
onToggle={toggleSelection}
/>
)}
<FileGrid
entries={data?.entries || []}
volume={volume}
onNavigate={navigateTo}
onPreview={setPreviewFile}
onContextMenu={handleContextMenu}
selectable
selected={selected}
onToggle={toggleSelection}
onShiftSelect={selectRange}
lastToggled={lastToggled}
/>
)}
</div>
{/* Preview Modal */}
{previewFile && (
<PreviewModal file={previewFile} volume={volume} onClose={() => setPreviewFile(null)} />
Expand Down
Loading