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:
Harivansh Rathi 2026-04-01 18:11:52 +00:00
parent 73e4d026bb
commit b74db855c8
24 changed files with 6215 additions and 774 deletions

View file

@ -1,42 +0,0 @@
.field {
display: grid;
gap: 10px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.label {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #527082;
}
.button {
border: 0;
border-radius: 999px;
padding: 9px 12px;
background: #123043;
color: #f7fbfc;
font: inherit;
font-size: 0.86rem;
cursor: pointer;
}
.value {
display: block;
overflow-wrap: anywhere;
padding: 12px 14px;
border-radius: 14px;
background: #f3f8f7;
border: 1px solid rgba(18, 48, 67, 0.08);
color: #123043;
font-size: 0.92rem;
line-height: 1.5;
}

View file

@ -1,29 +1,38 @@
"use client";
import { Button } from "@betternas/ui/button";
import { Code } from "@betternas/ui/code";
import { Check, Copy } from "@phosphor-icons/react";
import { useState } from "react";
import styles from "./copy-field.module.css";
import { Button } from "@/components/ui/button";
export function CopyField({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = useState(false);
return (
<div className={styles.field}>
<div className={styles.header}>
<span className={styles.label}>{label}</span>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</span>
<Button
className={styles.button}
size="xs"
variant="outline"
onClick={async () => {
await navigator.clipboard.writeText(value);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
}}
>
{copied ? (
<Check data-icon="inline-start" weight="bold" />
) : (
<Copy data-icon="inline-start" />
)}
{copied ? "Copied" : "Copy"}
</Button>
</div>
<Code className={styles.value}>{value}</Code>
<code className="block break-all rounded-lg bg-muted px-3 py-2 font-mono text-xs text-foreground">
{value}
</code>
</div>
);
}

View file

@ -1,50 +1,129 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: #ffffff;
--foreground: #171717;
--background: oklch(1 0 0);
--foreground: oklch(0.147 0.004 49.25);
--card: oklch(1 0 0);
--card-foreground: oklch(0.147 0.004 49.25);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.147 0.004 49.25);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0.001 106.424);
--muted-foreground: oklch(0.553 0.013 58.071);
--accent: oklch(0.97 0.001 106.424);
--accent-foreground: oklch(0.216 0.006 56.043);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.923 0.003 48.717);
--input: oklch(0.923 0.003 48.717);
--ring: oklch(0.709 0.01 56.259);
--chart-1: oklch(0.845 0.143 164.978);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.596 0.145 163.225);
--chart-4: oklch(0.508 0.118 165.612);
--chart-5: oklch(0.432 0.095 166.913);
--radius: 0.875rem;
--sidebar: oklch(0.985 0.001 106.423);
--sidebar-foreground: oklch(0.147 0.004 49.25);
--sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.97 0.001 106.424);
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
--sidebar-border: oklch(0.923 0.003 48.717);
--sidebar-ring: oklch(0.709 0.01 56.259);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
.dark {
--background: oklch(0.147 0.004 49.25);
--foreground: oklch(0.985 0.001 106.423);
--card: oklch(0.216 0.006 56.043);
--card-foreground: oklch(0.985 0.001 106.423);
--popover: oklch(0.216 0.006 56.043);
--popover-foreground: oklch(0.985 0.001 106.423);
--primary: oklch(0.424 0.199 265.638);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.268 0.007 34.298);
--muted-foreground: oklch(0.709 0.01 56.259);
--accent: oklch(0.268 0.007 34.298);
--accent-foreground: oklch(0.985 0.001 106.423);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.553 0.013 58.071);
--chart-1: oklch(0.845 0.143 164.978);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.596 0.145 163.225);
--chart-4: oklch(0.508 0.118 165.612);
--chart-5: oklch(0.432 0.095 166.913);
--sidebar: oklch(0.216 0.006 56.043);
--sidebar-foreground: oklch(0.985 0.001 106.423);
--sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.268 0.007 34.298);
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.553 0.013 58.071);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
color: var(--foreground);
background: var(--background);
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
.imgDark {
display: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
.imgLight {
display: none;
}
.imgDark {
display: unset;
@apply font-sans;
}
}

View file

@ -1,20 +1,15 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import { Manrope } from "next/font/google";
import "./globals.css";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
const manrope = Manrope({
subsets: ["latin"],
variable: "--font-sans",
});
export const metadata: Metadata = {
title: "betterNAS",
description:
"Contract-first monorepo for NAS mounts and optional cloud access",
description: "Mount NAS exports from your browser",
};
export default function RootLayout({
@ -23,10 +18,8 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
</body>
<html lang="en" className={manrope.variable}>
<body>{children}</body>
</html>
);
}

View file

@ -1,313 +0,0 @@
.page {
min-height: 100svh;
padding: 48px 24px 80px;
background:
radial-gradient(
circle at top left,
rgba(91, 186, 166, 0.18),
transparent 28%
),
linear-gradient(180deg, #f5fbfa 0%, #edf5f3 100%);
color: #10212d;
}
.hero {
max-width: 1200px;
margin: 0 auto 32px;
padding: 32px;
border-radius: 28px;
background: linear-gradient(135deg, #123043 0%, #1d5466 100%);
color: #f7fbfc;
box-shadow: 0 24px 80px rgba(16, 33, 45, 0.14);
}
.eyebrow {
margin: 0 0 12px;
font-size: 12px;
letter-spacing: 0.12em;
text-transform: uppercase;
opacity: 0.78;
}
.title {
margin: 0 0 12px;
font-size: clamp(2.25rem, 5vw, 4rem);
line-height: 0.98;
}
.copy {
margin: 0;
max-width: 64ch;
font-size: 1rem;
line-height: 1.7;
}
.heroMeta {
margin-top: 22px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
}
.heroMeta div {
padding: 16px;
border-radius: 18px;
background: rgba(247, 251, 252, 0.12);
border: 1px solid rgba(247, 251, 252, 0.14);
}
.heroMeta dt {
margin: 0 0 8px;
font-size: 0.76rem;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.76;
}
.heroMeta dd {
margin: 0;
font-size: 0.98rem;
line-height: 1.5;
}
.layout {
max-width: 1200px;
margin: 0 auto;
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr);
gap: 22px;
}
.panel {
display: grid;
align-content: start;
gap: 18px;
padding: 24px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(18, 48, 67, 0.1);
box-shadow: 0 18px 50px rgba(18, 48, 67, 0.1);
}
.panelHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.sectionEyebrow {
margin: 0;
font-size: 0.76rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #527082;
}
.sectionTitle {
margin: 8px 0 0;
font-size: clamp(1.4rem, 2vw, 1.85rem);
color: #10212d;
}
.sectionMeta {
align-self: center;
padding: 8px 12px;
border-radius: 999px;
background: #eff6f5;
color: #26485b;
font-size: 0.88rem;
}
.notice {
padding: 14px 16px;
border-radius: 18px;
background: #fff1de;
color: #7a4a12;
line-height: 1.6;
}
.emptyState {
padding: 22px;
border-radius: 22px;
background: #f3f8f7;
border: 1px dashed rgba(18, 48, 67, 0.16);
color: #395667;
line-height: 1.7;
}
.exportList {
display: grid;
gap: 14px;
}
.exportCard,
.exportCardSelected {
display: grid;
gap: 16px;
padding: 20px;
border-radius: 22px;
border: 1px solid rgba(18, 48, 67, 0.08);
background: #fbfdfd;
color: inherit;
text-decoration: none;
transition:
transform 140ms ease,
box-shadow 140ms ease,
border-color 140ms ease;
}
.exportCard:hover,
.exportCardSelected:hover {
transform: translateY(-1px);
box-shadow: 0 12px 26px rgba(18, 48, 67, 0.08);
}
.exportCardSelected {
border-color: rgba(29, 84, 102, 0.28);
background: linear-gradient(180deg, #f4fbfa 0%, #eef8f6 100%);
}
.exportCardTop {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.exportTitle {
margin: 0;
font-size: 1.1rem;
color: #123043;
}
.exportId {
margin: 6px 0 0;
color: #527082;
font-size: 0.92rem;
}
.exportProtocol {
flex-shrink: 0;
padding: 8px 10px;
border-radius: 999px;
background: #eaf3f1;
color: #1d5466;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.exportFacts,
.mountMeta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.exportFactWide {
grid-column: 1 / -1;
}
.exportFacts dt,
.mountMeta dt {
margin: 0 0 6px;
font-size: 0.76rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #5d7888;
}
.exportFacts dd,
.mountMeta dd {
margin: 0;
overflow-wrap: anywhere;
color: #123043;
line-height: 1.55;
}
.mountPanel {
display: grid;
gap: 20px;
}
.mountStatus {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.mountTitle {
margin: 8px 0 0;
font-size: 1.3rem;
color: #123043;
}
.mountBadge {
flex-shrink: 0;
padding: 8px 12px;
border-radius: 999px;
background: #123043;
color: #f7fbfc;
font-size: 0.84rem;
}
.copyFields {
display: grid;
gap: 14px;
}
.instructions {
padding: 18px 20px;
border-radius: 22px;
background: #f4f8fa;
border: 1px solid rgba(18, 48, 67, 0.08);
}
.instructionsTitle {
margin: 0 0 12px;
color: #123043;
font-size: 1rem;
}
.instructionsList {
padding-left: 20px;
color: #304e60;
line-height: 1.7;
}
.inlineCode {
padding: 2px 6px;
border-radius: 8px;
background: rgba(18, 48, 67, 0.08);
color: #123043;
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.page {
padding-inline: 16px;
}
.hero,
.panel {
padding: 24px;
}
.panelHeader,
.mountStatus,
.exportCardTop {
flex-direction: column;
}
.exportFacts,
.mountMeta {
grid-template-columns: 1fr;
}
}

View file

@ -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;
}