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"; "use client";
import { Button } from "@betternas/ui/button"; import { Check, Copy } from "@phosphor-icons/react";
import { Code } from "@betternas/ui/code";
import { useState } from "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 }) { export function CopyField({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
return ( return (
<div className={styles.field}> <div className="flex flex-col gap-1.5">
<div className={styles.header}> <div className="flex items-center justify-between gap-2">
<span className={styles.label}>{label}</span> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</span>
<Button <Button
className={styles.button} size="xs"
variant="outline"
onClick={async () => { onClick={async () => {
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
setCopied(true); setCopied(true);
window.setTimeout(() => setCopied(false), 1500); window.setTimeout(() => setCopied(false), 1500);
}} }}
> >
{copied ? (
<Check data-icon="inline-start" weight="bold" />
) : (
<Copy data-icon="inline-start" />
)}
{copied ? "Copied" : "Copy"} {copied ? "Copied" : "Copy"}
</Button> </Button>
</div> </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> </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 { :root {
--background: #ffffff; --background: oklch(1 0 0);
--foreground: #171717; --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) { .dark {
:root { --background: oklch(0.147 0.004 49.25);
--background: #0a0a0a; --foreground: oklch(0.985 0.001 106.423);
--foreground: #ededed; --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);
html, --primary: oklch(0.424 0.199 265.638);
body { --primary-foreground: oklch(0.97 0.014 254.604);
max-width: 100vw; --secondary: oklch(0.274 0.006 286.033);
overflow-x: hidden; --secondary-foreground: oklch(0.985 0 0);
} --muted: oklch(0.268 0.007 34.298);
--muted-foreground: oklch(0.709 0.01 56.259);
body { --accent: oklch(0.268 0.007 34.298);
color: var(--foreground); --accent-foreground: oklch(0.985 0.001 106.423);
background: var(--background); --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 {
* { * {
box-sizing: border-box; @apply border-border outline-ring/50;
padding: 0;
margin: 0;
} }
body {
a { @apply bg-background text-foreground;
color: inherit;
text-decoration: none;
} }
.imgDark {
display: none;
}
@media (prefers-color-scheme: dark) {
html { html {
color-scheme: dark; @apply font-sans;
}
.imgLight {
display: none;
}
.imgDark {
display: unset;
} }
} }

View file

@ -1,20 +1,15 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import localFont from "next/font/local"; import { Manrope } from "next/font/google";
import "./globals.css"; import "./globals.css";
const geistSans = localFont({ const manrope = Manrope({
src: "./fonts/GeistVF.woff", subsets: ["latin"],
variable: "--font-geist-sans", variable: "--font-sans",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "betterNAS", title: "betterNAS",
description: description: "Mount NAS exports from your browser",
"Contract-first monorepo for NAS mounts and optional cloud access",
}; };
export default function RootLayout({ export default function RootLayout({
@ -23,10 +18,8 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en" className={manrope.variable}>
<body className={`${geistSans.variable} ${geistMono.variable}`}> <body>{children}</body>
{children}
</body>
</html> </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 {
import { CopyField } from "./copy-field"; Globe,
HardDrives,
LinkSimple,
Warning,
} from "@phosphor-icons/react/dist/ssr";
import { import {
ControlPlaneConfigurationError, ControlPlaneConfigurationError,
ControlPlaneRequestError, ControlPlaneRequestError,
@ -8,15 +12,25 @@ import {
listExports, listExports,
type MountProfile, type MountProfile,
type StorageExport, type StorageExport,
} from "../lib/control-plane"; } from "@/lib/control-plane";
import styles from "./page.module.css"; 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"; export const dynamic = "force-dynamic";
interface PageProps { interface PageProps {
searchParams: Promise<{ searchParams: Promise<{ exportId?: string | string[] }>;
exportId?: string | string[];
}>;
} }
export default async function Home({ searchParams }: PageProps) { export default async function Home({ searchParams }: PageProps) {
@ -32,12 +46,10 @@ export default async function Home({ searchParams }: PageProps) {
exports = await listExports(); exports = await listExports();
if (selectedExportId !== null) { if (selectedExportId !== null) {
if ( if (exports.some((e) => e.id === selectedExportId)) {
exports.some((storageExport) => storageExport.id === selectedExportId)
) {
mountProfile = await issueMountProfile(selectedExportId); mountProfile = await issueMountProfile(selectedExportId);
} else { } else {
feedback = `Export ${selectedExportId} was not found in the current control-plane response.`; feedback = `Export "${selectedExportId}" was not found.`;
} }
} }
} catch (error) { } catch (error) {
@ -54,102 +66,129 @@ export default async function Home({ searchParams }: PageProps) {
const selectedExport = const selectedExport =
selectedExportId === null selectedExportId === null
? null ? null
: (exports.find( : (exports.find((e) => e.id === selectedExportId) ?? null);
(storageExport) => storageExport.id === selectedExportId,
) ?? null);
return ( return (
<main className={styles.page}> <main className="min-h-screen bg-background">
<section className={styles.hero}> <div className="mx-auto flex max-w-6xl flex-col gap-8 px-4 py-8 sm:px-6">
<p className={styles.eyebrow}>betterNAS control plane</p> <div className="flex flex-col gap-4">
<h1 className={styles.title}> <div className="flex flex-col gap-1">
Mount exports from the live control-plane. <p className="text-xs font-medium uppercase tracking-widest text-muted-foreground">
</h1> betterNAS
<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> </p>
<dl className={styles.heroMeta}> <h1 className="font-heading text-2xl font-semibold tracking-tight">
<div> Control Plane
<dt>Control-plane URL</dt> </h1>
<dd>{controlPlaneConfig.baseUrl ?? "Not configured"}</dd>
</div> </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}> <div className="flex flex-wrap items-center gap-2">
<section className={styles.panel}> <Badge variant="outline">
<div className={styles.panelHeader}> <Globe data-icon="inline-start" />
<div> {controlPlaneConfig.baseUrl ?? "Not configured"}
<p className={styles.sectionEyebrow}>Exports</p> </Badge>
<h2 className={styles.sectionTitle}> <Badge
Registered storage exports variant={
</h2> controlPlaneConfig.clientToken !== null
</div> ? "secondary"
<span className={styles.sectionMeta}> : "destructive"
}
>
{controlPlaneConfig.clientToken !== null
? "Bearer auth"
: "No token"}
</Badge>
<Badge variant="secondary">
{exports.length === 1 ? "1 export" : `${exports.length} exports`} {exports.length === 1 ? "1 export" : `${exports.length} exports`}
</span> </Badge>
</div>
</div> </div>
{feedback !== null ? ( {feedback !== null && (
<div className={styles.notice}>{feedback}</div> <Alert variant="destructive">
) : null} <Warning />
<AlertTitle>Configuration error</AlertTitle>
<AlertDescription>{feedback}</AlertDescription>
</Alert>
)}
<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 ? ( {exports.length === 0 ? (
<div className={styles.emptyState}> <div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-10 text-center">
No exports are registered yet. Start the node agent and verify the <HardDrives size={32} className="text-muted-foreground/40" />
control-plane connection first. <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>
) : ( ) : (
<div className={styles.exportList}> <div className="flex flex-col gap-2">
{exports.map((storageExport) => { {exports.map((storageExport) => {
const isSelected = storageExport.id === selectedExportId; const isSelected = storageExport.id === selectedExportId;
return ( return (
<a <a
key={storageExport.id} key={storageExport.id}
className={
isSelected ? styles.exportCardSelected : styles.exportCard
}
href={`/?exportId=${encodeURIComponent(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={styles.exportCardTop}> <div className="flex items-start justify-between gap-4">
<div> <div className="flex flex-col gap-0.5">
<h3 className={styles.exportTitle}> <span className="font-medium text-foreground">
{storageExport.label} {storageExport.label}
</h3> </span>
<p className={styles.exportId}>{storageExport.id}</p> <span className="text-xs text-muted-foreground">
</div> {storageExport.id}
<span className={styles.exportProtocol}>
{storageExport.protocols.join(", ")}
</span> </span>
</div> </div>
<Badge variant="secondary" className="shrink-0">
{storageExport.protocols.join(", ")}
</Badge>
</div>
<dl className={styles.exportFacts}> <dl className="grid grid-cols-2 gap-x-4 gap-y-2">
<div> <div>
<dt>Node</dt> <dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
<dd>{storageExport.nasNodeId}</dd> Node
</dt>
<dd className="truncate text-xs text-foreground">
{storageExport.nasNodeId}
</dd>
</div> </div>
<div> <div>
<dt>Mount path</dt> <dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
<dd>{storageExport.mountPath ?? "/dav/"}</dd> Mount path
</dt>
<dd className="text-xs text-foreground">
{storageExport.mountPath ?? "/dav/"}
</dd>
</div> </div>
<div className={styles.exportFactWide}> <div className="col-span-2">
<dt>Export path</dt> <dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
<dd>{storageExport.path}</dd> Export path
</dt>
<dd className="truncate text-xs text-foreground">
{storageExport.path}
</dd>
</div> </div>
</dl> </dl>
</a> </a>
@ -157,41 +196,50 @@ export default async function Home({ searchParams }: PageProps) {
})} })}
</div> </div>
)} )}
</section> </CardContent>
</Card>
<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>
<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 ? ( {mountProfile === null ? (
<div className={styles.emptyState}> <div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-10 text-center">
Pick an export to issue a WebDAV mount profile and reveal the URL, <LinkSimple size={32} className="text-muted-foreground/40" />
username, password, and expiry. <p className="text-sm text-muted-foreground">
Pick an export to issue WebDAV credentials for Finder.
</p>
</div> </div>
) : ( ) : (
<div className={styles.mountPanel}> <div className="flex flex-col gap-6">
<div className={styles.mountStatus}> <div className="flex items-center justify-between">
<div> <span className="text-xs text-muted-foreground">
<p className={styles.sectionEyebrow}>Issued profile</p> Issued profile
<h3 className={styles.mountTitle}>
{mountProfile.displayName}
</h3>
</div>
<span className={styles.mountBadge}>
{mountProfile.readonly ? "Read-only" : "Read-write"}
</span> </span>
<Badge
variant={mountProfile.readonly ? "secondary" : "default"}
>
{mountProfile.readonly ? "Read-only" : "Read-write"}
</Badge>
</div> </div>
<div className={styles.copyFields}> <Separator />
<CopyField label="Mount URL" value={mountProfile.mountUrl} />
<div className="flex flex-col gap-4">
<CopyField
label="Mount URL"
value={mountProfile.mountUrl}
/>
<CopyField <CopyField
label="Username" label="Username"
value={mountProfile.credential.username} value={mountProfile.credential.username}
@ -202,39 +250,56 @@ export default async function Home({ searchParams }: PageProps) {
/> />
</div> </div>
<dl className={styles.mountMeta}> <Separator />
<dl className="grid grid-cols-2 gap-x-4 gap-y-3">
<div> <div>
<dt>Credential mode</dt> <dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
<dd>{mountProfile.credential.mode}</dd> Mode
</dt>
<dd className="text-xs text-foreground">
{mountProfile.credential.mode}
</dd>
</div> </div>
<div> <div>
<dt>Expires at</dt> <dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
<dd>{mountProfile.credential.expiresAt}</dd> Expires
</dt>
<dd className="text-xs text-foreground">
{mountProfile.credential.expiresAt}
</dd>
</div> </div>
</dl> </dl>
<div className={styles.instructions}> <Separator />
<h3 className={styles.instructionsTitle}>Finder steps</h3>
<ol className={styles.instructionsList}> <div className="flex flex-col gap-3">
<li>Open Finder and choose Go, then Connect to Server.</li> <h3 className="text-sm font-medium">Finder steps</h3>
<li> <ol className="flex flex-col gap-2">
Paste{" "} {[
<Code className={styles.inlineCode}> "Open Finder and choose Go, then Connect to Server.",
{mountProfile.mountUrl} `Paste the mount URL into the server address field.`,
</Code> "Enter the issued username and password when prompted.",
. "Save to Keychain only if the credential expiry suits your workflow.",
</li> ].map((step, index) => (
<li>When prompted, use the issued username and password.</li> <li
<li> key={index}
Save credentials in Keychain only if the expiry fits your className="flex gap-2.5 text-sm text-muted-foreground"
workflow. >
<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> </li>
))}
</ol> </ol>
</div> </div>
</div> </div>
)} )}
</aside> </CardContent>
</section> </Card>
</div>
</div>
</main> </main>
); );
} }
@ -244,11 +309,8 @@ function readSearchParam(value: string | string[] | undefined): string | null {
return value.trim(); return value.trim();
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
const firstValue = value.find( const first = value.find((v) => v.trim() !== "");
(candidateValue) => candidateValue.trim() !== "", return first?.trim() ?? null;
);
return firstValue?.trim() ?? null;
} }
return null; return null;
} }

25
apps/web/components.json Normal file
View file

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-luma",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "stone",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "phosphor",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "inverted-translucent",
"menuAccent": "subtle",
"registries": {}
}

View file

@ -0,0 +1,76 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-2xl border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-heading font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className,
)}
{...props}
/>
);
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2.5 right-3", className)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription, AlertAction };

View file

@ -0,0 +1,49 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-3xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View file

@ -0,0 +1,65 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-4xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent dark:hover:bg-input/30",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-9",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View file

@ -0,0 +1,100 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-6 overflow-hidden rounded-4xl bg-card py-6 text-sm text-card-foreground shadow-md ring-1 ring-foreground/5 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 dark:ring-foreground/10 *:[img:first-child]:rounded-t-4xl *:[img:last-child]:rounded-b-4xl",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1.5 rounded-t-4xl px-6 group-data-[size=sm]/card:px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("font-heading text-base font-medium", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6 group-data-[size=sm]/card:px-4", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-4xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4",
className,
)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View file

@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className,
)}
{...props}
/>
);
}
export { Separator };

6
apps/web/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View file

@ -11,10 +11,19 @@
"check-types": "next typegen && tsc --noEmit" "check-types": "next typegen && tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@betternas/ui": "workspace:*", "@phosphor-icons/react": "^2.1.10",
"@tailwindcss/postcss": "^4.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"next": "16.2.0", "next": "16.2.0",
"postcss": "^8.5.8",
"radix-ui": "^1.4.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"shadcn": "^4.1.2",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@betternas/eslint-config": "workspace:*", "@betternas/eslint-config": "workspace:*",

View file

@ -0,0 +1,6 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View file

@ -6,7 +6,11 @@
"name": "next" "name": "next"
} }
], ],
"strictNullChecks": true "strictNullChecks": true,
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}, },
"include": ["**/*.ts", "**/*.tsx", "next-env.d.ts", "next.config.js"], "include": ["**/*.ts", "**/*.tsx", "next-env.d.ts", "next.config.js"],
"exclude": ["node_modules"] "exclude": ["node_modules"]

View file

@ -1,4 +0,0 @@
import { config } from "@betternas/eslint-config/react-internal";
/** @type {import("eslint").Linter.Config} */
export default config;

View file

@ -1,27 +0,0 @@
{
"name": "@betternas/ui",
"version": "0.0.0",
"private": true,
"exports": {
"./*": "./src/*.tsx"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"lint": "eslint . --max-warnings 0",
"generate:component": "turbo gen react-component",
"check-types": "tsc --noEmit"
},
"devDependencies": {
"@betternas/eslint-config": "workspace:*",
"@betternas/typescript-config": "workspace:*",
"@types/node": "^22.15.3",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"eslint": "^9.39.1",
"typescript": "5.9.2"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
}

View file

@ -1,17 +0,0 @@
"use client";
import { ReactNode } from "react";
interface ButtonProps {
children: ReactNode;
className?: string;
onClick?: () => void;
}
export const Button = ({ children, className, onClick }: ButtonProps) => {
return (
<button className={className} onClick={onClick} type="button">
{children}
</button>
);
};

View file

@ -1,22 +0,0 @@
import { type JSX } from "react";
export function Card({
className,
title,
children,
href,
}: {
className?: string;
title: string;
children: React.ReactNode;
href: string;
}): JSX.Element {
return (
<a className={className} href={href}>
<h2>
{title} <span>-&gt;</span>
</h2>
<p>{children}</p>
</a>
);
}

View file

@ -1,11 +0,0 @@
import { type JSX } from "react";
export function Code({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}): JSX.Element {
return <code className={className}>{children}</code>;
}

View file

@ -1,9 +0,0 @@
{
"extends": "@betternas/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"strictNullChecks": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

5528
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,7 @@
"$schema": "https://turborepo.dev/schema.json", "$schema": "https://turborepo.dev/schema.json",
"ui": "tui", "ui": "tui",
"globalEnv": [ "globalEnv": [
"BETTERNAS_CLONE_NAME",
"BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN", "BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN",
"BETTERNAS_CONTROL_PLANE_PORT", "BETTERNAS_CONTROL_PLANE_PORT",
"BETTERNAS_CONTROL_PLANE_URL" "BETTERNAS_CONTROL_PLANE_URL"