mirror of
https://github.com/harivansh-afk/betterNAS.git
synced 2026-04-15 07:04:44 +00:00
Migrate apps/web to shadcn/ui with preset b2D1F1IZv
Replace custom CSS module UI with shadcn radix-luma components, Tailwind v4, and Manrope font. Remove packages/ui.
This commit is contained in:
parent
73e4d026bb
commit
b74db855c8
24 changed files with 6215 additions and 774 deletions
|
|
@ -1,5 +1,9 @@
|
|||
import { Code } from "@betternas/ui/code";
|
||||
import { CopyField } from "./copy-field";
|
||||
import {
|
||||
Globe,
|
||||
HardDrives,
|
||||
LinkSimple,
|
||||
Warning,
|
||||
} from "@phosphor-icons/react/dist/ssr";
|
||||
import {
|
||||
ControlPlaneConfigurationError,
|
||||
ControlPlaneRequestError,
|
||||
|
|
@ -8,15 +12,25 @@ import {
|
|||
listExports,
|
||||
type MountProfile,
|
||||
type StorageExport,
|
||||
} from "../lib/control-plane";
|
||||
import styles from "./page.module.css";
|
||||
} from "@/lib/control-plane";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CopyField } from "./copy-field";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{
|
||||
exportId?: string | string[];
|
||||
}>;
|
||||
searchParams: Promise<{ exportId?: string | string[] }>;
|
||||
}
|
||||
|
||||
export default async function Home({ searchParams }: PageProps) {
|
||||
|
|
@ -32,12 +46,10 @@ export default async function Home({ searchParams }: PageProps) {
|
|||
exports = await listExports();
|
||||
|
||||
if (selectedExportId !== null) {
|
||||
if (
|
||||
exports.some((storageExport) => storageExport.id === selectedExportId)
|
||||
) {
|
||||
if (exports.some((e) => e.id === selectedExportId)) {
|
||||
mountProfile = await issueMountProfile(selectedExportId);
|
||||
} else {
|
||||
feedback = `Export ${selectedExportId} was not found in the current control-plane response.`;
|
||||
feedback = `Export "${selectedExportId}" was not found.`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -54,187 +66,240 @@ export default async function Home({ searchParams }: PageProps) {
|
|||
const selectedExport =
|
||||
selectedExportId === null
|
||||
? null
|
||||
: (exports.find(
|
||||
(storageExport) => storageExport.id === selectedExportId,
|
||||
) ?? null);
|
||||
: (exports.find((e) => e.id === selectedExportId) ?? null);
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<section className={styles.hero}>
|
||||
<p className={styles.eyebrow}>betterNAS control plane</p>
|
||||
<h1 className={styles.title}>
|
||||
Mount exports from the live control-plane.
|
||||
</h1>
|
||||
<p className={styles.copy}>
|
||||
This page reads the running control-plane, lists available exports,
|
||||
and issues Finder-friendly WebDAV mount credentials for the export you
|
||||
select.
|
||||
</p>
|
||||
<dl className={styles.heroMeta}>
|
||||
<div>
|
||||
<dt>Control-plane URL</dt>
|
||||
<dd>{controlPlaneConfig.baseUrl ?? "Not configured"}</dd>
|
||||
<main className="min-h-screen bg-background">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-8 px-4 py-8 sm:px-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-xs font-medium uppercase tracking-widest text-muted-foreground">
|
||||
betterNAS
|
||||
</p>
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-tight">
|
||||
Control Plane
|
||||
</h1>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Auth mode</dt>
|
||||
<dd>
|
||||
{controlPlaneConfig.clientToken === null
|
||||
? "Missing client token"
|
||||
: "Server-side bearer token"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Exports discovered</dt>
|
||||
<dd>{exports.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className={styles.layout}>
|
||||
<section className={styles.panel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<p className={styles.sectionEyebrow}>Exports</p>
|
||||
<h2 className={styles.sectionTitle}>
|
||||
Registered storage exports
|
||||
</h2>
|
||||
</div>
|
||||
<span className={styles.sectionMeta}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
<Globe data-icon="inline-start" />
|
||||
{controlPlaneConfig.baseUrl ?? "Not configured"}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={
|
||||
controlPlaneConfig.clientToken !== null
|
||||
? "secondary"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{controlPlaneConfig.clientToken !== null
|
||||
? "Bearer auth"
|
||||
: "No token"}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{exports.length === 1 ? "1 export" : `${exports.length} exports`}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{feedback !== null ? (
|
||||
<div className={styles.notice}>{feedback}</div>
|
||||
) : null}
|
||||
{feedback !== null && (
|
||||
<Alert variant="destructive">
|
||||
<Warning />
|
||||
<AlertTitle>Configuration error</AlertTitle>
|
||||
<AlertDescription>{feedback}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{exports.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
No exports are registered yet. Start the node agent and verify the
|
||||
control-plane connection first.
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.exportList}>
|
||||
{exports.map((storageExport) => {
|
||||
const isSelected = storageExport.id === selectedExportId;
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_400px]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Exports</CardTitle>
|
||||
<CardDescription>
|
||||
Storage exports registered with this control plane.
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Badge variant="secondary">
|
||||
{exports.length === 1
|
||||
? "1 export"
|
||||
: `${exports.length} exports`}
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{exports.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-10 text-center">
|
||||
<HardDrives size={32} className="text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No exports registered yet. Start the node agent and connect
|
||||
it to this control plane.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{exports.map((storageExport) => {
|
||||
const isSelected = storageExport.id === selectedExportId;
|
||||
|
||||
return (
|
||||
<a
|
||||
key={storageExport.id}
|
||||
className={
|
||||
isSelected ? styles.exportCardSelected : styles.exportCard
|
||||
}
|
||||
href={`/?exportId=${encodeURIComponent(storageExport.id)}`}
|
||||
>
|
||||
<div className={styles.exportCardTop}>
|
||||
<div>
|
||||
<h3 className={styles.exportTitle}>
|
||||
{storageExport.label}
|
||||
</h3>
|
||||
<p className={styles.exportId}>{storageExport.id}</p>
|
||||
</div>
|
||||
<span className={styles.exportProtocol}>
|
||||
{storageExport.protocols.join(", ")}
|
||||
</span>
|
||||
return (
|
||||
<a
|
||||
key={storageExport.id}
|
||||
href={`/?exportId=${encodeURIComponent(storageExport.id)}`}
|
||||
className={cn(
|
||||
"flex flex-col gap-3 rounded-2xl border p-4 text-sm transition-colors",
|
||||
isSelected
|
||||
? "border-primary/20 bg-primary/5"
|
||||
: "border-border hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium text-foreground">
|
||||
{storageExport.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{storageExport.id}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{storageExport.protocols.join(", ")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
<div>
|
||||
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Node
|
||||
</dt>
|
||||
<dd className="truncate text-xs text-foreground">
|
||||
{storageExport.nasNodeId}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Mount path
|
||||
</dt>
|
||||
<dd className="text-xs text-foreground">
|
||||
{storageExport.mountPath ?? "/dav/"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Export path
|
||||
</dt>
|
||||
<dd className="truncate text-xs text-foreground">
|
||||
{storageExport.path}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{selectedExport !== null
|
||||
? `Mount ${selectedExport.label}`
|
||||
: "Mount instructions"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedExport !== null
|
||||
? "Issued WebDAV credentials for Finder."
|
||||
: "Select an export to issue mount credentials."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mountProfile === null ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-10 text-center">
|
||||
<LinkSimple size={32} className="text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pick an export to issue WebDAV credentials for Finder.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Issued profile
|
||||
</span>
|
||||
<Badge
|
||||
variant={mountProfile.readonly ? "secondary" : "default"}
|
||||
>
|
||||
{mountProfile.readonly ? "Read-only" : "Read-write"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<CopyField
|
||||
label="Mount URL"
|
||||
value={mountProfile.mountUrl}
|
||||
/>
|
||||
<CopyField
|
||||
label="Username"
|
||||
value={mountProfile.credential.username}
|
||||
/>
|
||||
<CopyField
|
||||
label="Password"
|
||||
value={mountProfile.credential.password}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<div>
|
||||
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Mode
|
||||
</dt>
|
||||
<dd className="text-xs text-foreground">
|
||||
{mountProfile.credential.mode}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Expires
|
||||
</dt>
|
||||
<dd className="text-xs text-foreground">
|
||||
{mountProfile.credential.expiresAt}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<dl className={styles.exportFacts}>
|
||||
<div>
|
||||
<dt>Node</dt>
|
||||
<dd>{storageExport.nasNodeId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Mount path</dt>
|
||||
<dd>{storageExport.mountPath ?? "/dav/"}</dd>
|
||||
</div>
|
||||
<div className={styles.exportFactWide}>
|
||||
<dt>Export path</dt>
|
||||
<dd>{storageExport.path}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<Separator />
|
||||
|
||||
<aside className={styles.panel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<p className={styles.sectionEyebrow}>Mount instructions</p>
|
||||
<h2 className={styles.sectionTitle}>
|
||||
{selectedExport === null
|
||||
? "Select an export"
|
||||
: `Mount ${selectedExport.label}`}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mountProfile === null ? (
|
||||
<div className={styles.emptyState}>
|
||||
Pick an export to issue a WebDAV mount profile and reveal the URL,
|
||||
username, password, and expiry.
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.mountPanel}>
|
||||
<div className={styles.mountStatus}>
|
||||
<div>
|
||||
<p className={styles.sectionEyebrow}>Issued profile</p>
|
||||
<h3 className={styles.mountTitle}>
|
||||
{mountProfile.displayName}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-medium">Finder steps</h3>
|
||||
<ol className="flex flex-col gap-2">
|
||||
{[
|
||||
"Open Finder and choose Go, then Connect to Server.",
|
||||
`Paste the mount URL into the server address field.`,
|
||||
"Enter the issued username and password when prompted.",
|
||||
"Save to Keychain only if the credential expiry suits your workflow.",
|
||||
].map((step, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="flex gap-2.5 text-sm text-muted-foreground"
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<span className={styles.mountBadge}>
|
||||
{mountProfile.readonly ? "Read-only" : "Read-write"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.copyFields}>
|
||||
<CopyField label="Mount URL" value={mountProfile.mountUrl} />
|
||||
<CopyField
|
||||
label="Username"
|
||||
value={mountProfile.credential.username}
|
||||
/>
|
||||
<CopyField
|
||||
label="Password"
|
||||
value={mountProfile.credential.password}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<dl className={styles.mountMeta}>
|
||||
<div>
|
||||
<dt>Credential mode</dt>
|
||||
<dd>{mountProfile.credential.mode}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Expires at</dt>
|
||||
<dd>{mountProfile.credential.expiresAt}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className={styles.instructions}>
|
||||
<h3 className={styles.instructionsTitle}>Finder steps</h3>
|
||||
<ol className={styles.instructionsList}>
|
||||
<li>Open Finder and choose Go, then Connect to Server.</li>
|
||||
<li>
|
||||
Paste{" "}
|
||||
<Code className={styles.inlineCode}>
|
||||
{mountProfile.mountUrl}
|
||||
</Code>
|
||||
.
|
||||
</li>
|
||||
<li>When prompted, use the issued username and password.</li>
|
||||
<li>
|
||||
Save credentials in Keychain only if the expiry fits your
|
||||
workflow.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</section>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -244,11 +309,8 @@ function readSearchParam(value: string | string[] | undefined): string | null {
|
|||
return value.trim();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const firstValue = value.find(
|
||||
(candidateValue) => candidateValue.trim() !== "",
|
||||
);
|
||||
return firstValue?.trim() ?? null;
|
||||
const first = value.find((v) => v.trim() !== "");
|
||||
return first?.trim() ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue