mirror of
https://github.com/harivansh-afk/betterNAS.git
synced 2026-04-15 05:02:07 +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,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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,102 +66,129 @@ 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.
|
||||
<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>
|
||||
<dl className={styles.heroMeta}>
|
||||
<div>
|
||||
<dt>Control-plane URL</dt>
|
||||
<dd>{controlPlaneConfig.baseUrl ?? "Not configured"}</dd>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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={styles.emptyState}>
|
||||
No exports are registered yet. Start the node agent and verify the
|
||||
control-plane connection first.
|
||||
<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={styles.exportList}>
|
||||
<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)}`}
|
||||
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>
|
||||
<h3 className={styles.exportTitle}>
|
||||
<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}
|
||||
</h3>
|
||||
<p className={styles.exportId}>{storageExport.id}</p>
|
||||
</div>
|
||||
<span className={styles.exportProtocol}>
|
||||
{storageExport.protocols.join(", ")}
|
||||
</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={styles.exportFacts}>
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
<div>
|
||||
<dt>Node</dt>
|
||||
<dd>{storageExport.nasNodeId}</dd>
|
||||
<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>Mount path</dt>
|
||||
<dd>{storageExport.mountPath ?? "/dav/"}</dd>
|
||||
<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={styles.exportFactWide}>
|
||||
<dt>Export path</dt>
|
||||
<dd>{storageExport.path}</dd>
|
||||
<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>
|
||||
|
|
@ -157,41 +196,50 @@ export default async function Home({ searchParams }: PageProps) {
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<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>
|
||||
</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={styles.emptyState}>
|
||||
Pick an export to issue a WebDAV mount profile and reveal the URL,
|
||||
username, password, and expiry.
|
||||
<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={styles.mountPanel}>
|
||||
<div className={styles.mountStatus}>
|
||||
<div>
|
||||
<p className={styles.sectionEyebrow}>Issued profile</p>
|
||||
<h3 className={styles.mountTitle}>
|
||||
{mountProfile.displayName}
|
||||
</h3>
|
||||
</div>
|
||||
<span className={styles.mountBadge}>
|
||||
{mountProfile.readonly ? "Read-only" : "Read-write"}
|
||||
<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>
|
||||
|
||||
<div className={styles.copyFields}>
|
||||
<CopyField label="Mount URL" value={mountProfile.mountUrl} />
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<CopyField
|
||||
label="Mount URL"
|
||||
value={mountProfile.mountUrl}
|
||||
/>
|
||||
<CopyField
|
||||
label="Username"
|
||||
value={mountProfile.credential.username}
|
||||
|
|
@ -202,39 +250,56 @@ export default async function Home({ searchParams }: PageProps) {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<dl className={styles.mountMeta}>
|
||||
<Separator />
|
||||
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<div>
|
||||
<dt>Credential mode</dt>
|
||||
<dd>{mountProfile.credential.mode}</dd>
|
||||
<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>Expires at</dt>
|
||||
<dd>{mountProfile.credential.expiresAt}</dd>
|
||||
<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>
|
||||
|
||||
<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.
|
||||
<Separator />
|
||||
|
||||
<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>
|
||||
)}
|
||||
</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;
|
||||
}
|
||||
|
|
|
|||
25
apps/web/components.json
Normal file
25
apps/web/components.json
Normal 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": {}
|
||||
}
|
||||
76
apps/web/components/ui/alert.tsx
Normal file
76
apps/web/components/ui/alert.tsx
Normal 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 };
|
||||
49
apps/web/components/ui/badge.tsx
Normal file
49
apps/web/components/ui/badge.tsx
Normal 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 };
|
||||
65
apps/web/components/ui/button.tsx
Normal file
65
apps/web/components/ui/button.tsx
Normal 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 };
|
||||
100
apps/web/components/ui/card.tsx
Normal file
100
apps/web/components/ui/card.tsx
Normal 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,
|
||||
};
|
||||
28
apps/web/components/ui/separator.tsx
Normal file
28
apps/web/components/ui/separator.tsx
Normal 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
6
apps/web/lib/utils.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -11,10 +11,19 @@
|
|||
"check-types": "next typegen && tsc --noEmit"
|
||||
},
|
||||
"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",
|
||||
"postcss": "^8.5.8",
|
||||
"radix-ui": "^1.4.3",
|
||||
"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": {
|
||||
"@betternas/eslint-config": "workspace:*",
|
||||
|
|
|
|||
6
apps/web/postcss.config.mjs
Normal file
6
apps/web/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
|
|
@ -6,7 +6,11 @@
|
|||
"name": "next"
|
||||
}
|
||||
],
|
||||
"strictNullChecks": true
|
||||
"strictNullChecks": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", "next-env.d.ts", "next.config.js"],
|
||||
"exclude": ["node_modules"]
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
import { config } from "@betternas/eslint-config/react-internal";
|
||||
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
export default config;
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>-></span>
|
||||
</h2>
|
||||
<p>{children}</p>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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
5528
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -2,6 +2,7 @@
|
|||
"$schema": "https://turborepo.dev/schema.json",
|
||||
"ui": "tui",
|
||||
"globalEnv": [
|
||||
"BETTERNAS_CLONE_NAME",
|
||||
"BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN",
|
||||
"BETTERNAS_CONTROL_PLANE_PORT",
|
||||
"BETTERNAS_CONTROL_PLANE_URL"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue