From fdafe1a526b082a9a0ccf894a0d2a6c6a7020345 Mon Sep 17 00:00:00 2001 From: devswithme Date: Thu, 13 Nov 2025 21:37:25 +0800 Subject: [PATCH] feat: can add multiple comps, concising output --- cli/package-lock.json | 26 +++++- cli/package.json | 3 +- cli/src/commands/add.ts | 136 +++++++++++++++++----------- cli/src/commands/init.ts | 23 ++--- cli/src/utils/constants.ts | 1 + cli/ui.json | 5 + ui/accordion/index.css | 36 ++++++++ ui/accordion/index.tsx | 54 +++++++++++ ui/button/index.css | 110 ++++++++++++++++++++++ ui/button/index.tsx | 22 +++++ ui/content/docs/components/tabs.mdx | 20 ++-- ui/index.css | 65 +++++++++++++ ui/public/trakteer/tabs/index.css | 63 ++++++------- 13 files changed, 461 insertions(+), 103 deletions(-) create mode 100644 cli/ui.json create mode 100644 ui/accordion/index.css create mode 100644 ui/accordion/index.tsx create mode 100644 ui/button/index.css create mode 100644 ui/button/index.tsx create mode 100644 ui/index.css diff --git a/cli/package-lock.json b/cli/package-lock.json index 31e746e..ed5dff4 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "chalk": "^5.6.2", "commander": "^14.0.1", - "inquirer": "^12.9.6" + "inquirer": "^12.9.6", + "lucide-react": "^0.553.0" }, "bin": { "fydemy-ui": "dist/cli.js" @@ -362,7 +363,7 @@ "version": "24.7.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.1.tgz", "integrity": "sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.14.0" @@ -503,6 +504,15 @@ "node": ">=8" } }, + "node_modules/lucide-react": { + "version": "0.553.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.553.0.tgz", + "integrity": "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/mute-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", @@ -512,6 +522,16 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/run-async": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/run-async/-/run-async-4.0.6.tgz", @@ -598,7 +618,7 @@ "version": "7.14.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/wrap-ansi": { diff --git a/cli/package.json b/cli/package.json index 9e90962..7785ac5 100644 --- a/cli/package.json +++ b/cli/package.json @@ -53,7 +53,8 @@ "dependencies": { "chalk": "^5.6.2", "commander": "^14.0.1", - "inquirer": "^12.9.6" + "inquirer": "^12.9.6", + "lucide-react": "^0.553.0" }, "devDependencies": { "@types/node": "^24.7.1", diff --git a/cli/src/commands/add.ts b/cli/src/commands/add.ts index cf7cff0..0e58f46 100644 --- a/cli/src/commands/add.ts +++ b/cli/src/commands/add.ts @@ -1,30 +1,32 @@ import { Command } from "commander"; import { mkdir, writeFile } from "fs/promises"; -import { join, resolve } from "path"; +import path, { join, resolve } from "path"; import chalk from "chalk"; import { existsSync, readFileSync } from "fs"; import { fetchWithTimeout } from "../utils/fetch.js"; import type { InputType } from "../utils/types.js"; -import { ASSET_URL, DEFAULT_FILES } from "../utils/constants.js"; +import { ASSET_URL, DEFAULT_FILES, TIMEOUTMS } from "../utils/constants.js"; +import { writeConfigFile } from "./init.js"; +import inquirer from "inquirer"; -function loadConfig(configPath: string): InputType { +async function loadConfig(configPath: string): Promise { const defaults: InputType = { path: "app/components/ui", theme: "trakteer", pm: "npm", }; - if (!existsSync(configPath)) return defaults; - - try { - const raw = readFileSync(configPath, "utf-8"); - const parsed = JSON.parse(raw) as Partial | null; - if (!parsed) return defaults; - return { ...defaults, ...parsed }; - } catch (err) { - console.warn(chalk.yellow(`Failed to read ${configPath}, using defaults`)); + if (!existsSync(configPath)) { + await writeConfigFile(configPath, defaults); + console.log(`${chalk.yellow(`ℹ`)} Added default ui.json`); return defaults; } + + const parsed = JSON.parse( + readFileSync(configPath, "utf-8") + ) as Partial | null; + + return { ...defaults, ...parsed }; } async function writeComponentFiles( @@ -34,69 +36,101 @@ async function writeComponentFiles( mkdirFn = mkdir, writeFileFn = writeFile ) { - await mkdirFn(outputDir, { recursive: true }); + const fileContents: Record = {}; for (const filename of files) { try { - const content = await fetchBase(filename); - await writeFileFn(join(outputDir, filename), content, "utf8"); - console.log(chalk.green(`Wrote ${join(outputDir, filename)}`)); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - console.warn(chalk.yellow(`Skipping ${filename}: ${msg}`)); + fileContents[filename] = await fetchBase(filename); + } catch (err) { + throw new Error(`Component not found`); + } + } + + if (existsSync(outputDir)) { + const { overwrite } = await inquirer.prompt([ + { + type: "confirm", + name: "overwrite", + message: `Overwrite ${path.relative(process.cwd(), outputDir)}?`, + default: false, + }, + ]); + + if (!overwrite) { + return false; } } + + await mkdirFn(outputDir, { recursive: true }); + + for (const filename of files) { + await writeFileFn( + join(outputDir, filename), + fileContents[filename]!, + "utf8" + ); + } + + return true; } export async function runAdd( name: string, - options?: { - cwd?: string; - assetUrl?: string; - files?: string[]; - timeoutMs?: number; + config: InputType, + options: { + assetUrl: string; + files: string[]; + timeoutMs: number; } ) { - const cwd = options?.cwd ?? process.cwd(); - const configPath = resolve(cwd, "ui.json"); - const config = loadConfig(configPath); - - const files = options?.files ?? DEFAULT_FILES; - const assetBase = options?.assetUrl ?? ASSET_URL; - const timeoutMs = options?.timeoutMs ?? 10000; + const { assetUrl, files, timeoutMs } = options; const fetchBase = async (filename: string) => { - const url = `${assetBase}/raw/${config.theme}/${name}/${filename}`; + const url = `${assetUrl}/raw/${config.theme}/${name}/${filename}`; const res = await fetchWithTimeout(url, timeoutMs); - if (!res.ok) - throw new Error( - `Failed to fetch ${url}: ${res.status} ${res.statusText}` - ); + + if (!res.ok) { + throw new Error("Not found"); + } + return res.text(); }; - const outputPath = join(resolve(cwd), `${config.path}/${name}`); - await writeComponentFiles(outputPath, files, fetchBase); + const outputPath = join(resolve(process.cwd()), `${config.path}/${name}`); + const success = await writeComponentFiles(outputPath, files, fetchBase); - return { success: true, outputPath }; + return { success, outputPath }; } export function add(program: Command) { program - .command("add ") + .command("add ") .description("Add a new component to your path") - .action(async (name: string) => { - try { - const result = await runAdd(name); - if (result.success) { - console.log( - chalk.blue(`Component ${name} added at ${result.outputPath}`) - ); + .action(async (names: string[]) => { + const config = await loadConfig(resolve(process.cwd(), "ui.json")); + + for (const name of names) { + try { + const result = await runAdd(name, config, { + assetUrl: ASSET_URL, + timeoutMs: TIMEOUTMS, + files: DEFAULT_FILES, + }); + + if (result.success) { + console.log( + `${chalk.green(`✔`)} Added ${path.relative( + process.cwd(), + result.outputPath + )}` + ); + } else { + console.error(`${chalk.yellow(`⚠`)} Skipping ${name}`); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.error(`${chalk.red(`✖`)} ${message}`); } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - console.error(chalk.red(`Add failed: ${message}`)); - process.exitCode = 1; } }); } diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 84c576b..09644dc 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -59,16 +59,14 @@ async function promptInitOptions(): Promise { function installPackage(pm: string, pkg: string) { return new Promise((resolve, reject) => { - exec(`${pm} install ${pkg}`, (err, stdout, stderr) => { + exec(`${pm} install ${pkg}`, (err) => { if (err) return reject(err); - console.log(stdout); - console.error(stderr); resolve(); }); }); } -async function writeConfigFile(configPath: string, payload: unknown) { +export async function writeConfigFile(configPath: string, payload: InputType) { const data = JSON.stringify(payload, null, 2); await writeFile(configPath, data, "utf8"); } @@ -95,11 +93,11 @@ export function init(ui: Command) { const configPath = path.resolve(process.cwd(), "ui.json"); await writeConfigFile(configPath, answers); - console.log(chalk.blue(`Created ui.json at ${configPath}`)); + console.log(`${chalk.green(`✔`)} Adding ${chalk.green("ui.json")}`); const url = `${ASSET_URL}/raw/${answers.theme}/index.css`; - console.log(chalk.gray(`Fetching theme CSS from ${url} ...`)); - const res = await fetchWithTimeout(url, 15_000); + console.log(`${chalk.green(`✔`)} Fetching theme`); + const res = await fetchWithTimeout(url, 10000); if (!res.ok) { throw new Error( @@ -114,14 +112,17 @@ export function init(ui: Command) { await writeAsset(outputPath, "index.css", content); - console.log(chalk.gray(`Installing lucide-react ...`)); await installPackage(answers.pm, "lucide-react"); - console.log(chalk.green("lucide-react installed.")); + console.log(`${chalk.green(`✔`)} Installing dependencies`); - console.log(chalk.green("Initialization complete.")); + console.log( + `\n${chalk.green( + "Success!" + )} Project initialization completed.\nYou may now add components` + ); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); - console.error(chalk.red("Init failed:"), message); + console.error(`${chalk.red(`✖`)} ${message}`); try { process.exitCode = 1; } catch {} diff --git a/cli/src/utils/constants.ts b/cli/src/utils/constants.ts index 04c575f..2a74af8 100644 --- a/cli/src/utils/constants.ts +++ b/cli/src/utils/constants.ts @@ -1,2 +1,3 @@ export const ASSET_URL = "https://fydemy-ui.vercel.app"; export const DEFAULT_FILES = ["index.tsx", "index.css"]; +export const TIMEOUTMS = 10000; diff --git a/cli/ui.json b/cli/ui.json new file mode 100644 index 0000000..9dc5819 --- /dev/null +++ b/cli/ui.json @@ -0,0 +1,5 @@ +{ + "path": "app/components/ui", + "theme": "trakteer", + "pm": "npm" +} \ No newline at end of file diff --git a/ui/accordion/index.css b/ui/accordion/index.css new file mode 100644 index 0000000..1a697e5 --- /dev/null +++ b/ui/accordion/index.css @@ -0,0 +1,36 @@ +div.accordion[data-theme="trakteer"] { + font-family: "Pontano Sans", sans-serif; + display: flex; + flex-direction: column; + gap: 1rem; +} + +div.accordion[data-theme="trakteer"] + > div.accordion_item + > div.accordion_header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-bottom: 1px solid var(--trakteer-dark); + font-size: 18px; + font-weight: 600; +} + +div.accordion[data-theme="trakteer"] > div.accordion_item_active { + border-radius: 10px; + border: 1px solid var(--trakteer-dark); +} + +div.accordion[data-theme="trakteer"] + > div.accordion_item_active + > div.accordion_header { + border-bottom: none; + padding-bottom: 0; +} + +div.accordion[data-theme="trakteer"] + > div.accordion_item + div.accordion_content { + padding: 0 1rem; +} diff --git a/ui/accordion/index.tsx b/ui/accordion/index.tsx new file mode 100644 index 0000000..b5db3e0 --- /dev/null +++ b/ui/accordion/index.tsx @@ -0,0 +1,54 @@ +"use client"; + +import "../index.css"; +import "./index.css"; + +import { useState } from "react"; +import { Plus, X } from "lucide-react"; + +interface AccordionProps { + children: React.ReactNode; +} + +interface AccordionItemProps { + header: React.ReactNode; + children: React.ReactNode; + open?: boolean; + onOpenChange?: () => void; +} + +const Accordion = ({ children }: AccordionProps) => { + return ( +
+ {children} +
+ ); +}; + +const AccordionItem = ({ + header, + children, + open, + onOpenChange, +}: AccordionItemProps) => { + const [internalOpen, setInternalOpen] = useState(false); + + const isControlled = open !== undefined && onOpenChange !== undefined; + + const isOpen = isControlled ? open : internalOpen; + const handleToggle = isControlled + ? onOpenChange + : () => setInternalOpen(!internalOpen); + + return ( +
+
+ {header} + {isOpen ? : } +
+ {isOpen &&
{children}
} +
+ ); +}; + +export { Accordion, AccordionItem }; diff --git a/ui/button/index.css b/ui/button/index.css new file mode 100644 index 0000000..1b2097c --- /dev/null +++ b/ui/button/index.css @@ -0,0 +1,110 @@ +button[data-theme="trakteer"] { + font-family: "Fredoka", sans-serif; + padding: 8px 16px !important; + height: 42px; + display: flex; + align-items: center; + border-radius: 12px; +} + +button[data-theme="trakteer"] > :not(svg) { + display: flex; + align-items: center; + gap: 4px; +} + +button[data-theme="trakteer"] svg { + width: 16px; + height: 16px; +} + +button[data-theme="trakteer"][data-variant="default"] { + color: var(--trakteer-default-color); + background: var(--trakteer-default); + border: 1px solid var(--trakteer-default-border); + border-bottom: 4px solid var(--trakteer-default-border); +} + +button[data-theme="trakteer"][data-style="shadow"]:hover { + border-bottom: 1px solid var(--trakteer-default-border); + filter: brightness(1.05); + cursor: pointer; +} + +button[data-theme="trakteer"]:not([data-style="shadow"]):hover { + filter: brightness(1.05); + cursor: pointer; +} + +button[data-theme="trakteer"][data-variant="default"][data-style="default"] { + border: none; +} + +button[data-theme="trakteer"][data-variant="default"][data-style="icon"] { + border: none; + border-radius: 100%; + width: 42px; + padding: 0 !important; + display: flex; + align-items: center; + justify-content: center; +} + +button[data-theme="trakteer"][data-variant="outline"] { + color: var(--trakteer-outline-color); + background: var(--trakteer-outline); + border: 1px solid var(--trakteer-outline-border); + border-bottom: 4px solid var(--trakteer-outline-border); +} + +button[data-variant="outline"][data-style="default"] { + border: none; +} + +button[data-variant="outline"][data-style="icon"] { + border: none; + border-radius: 100%; + width: 42px; + padding: 0 !important; + display: flex; + align-items: center; + justify-content: center; +} + +button[data-variant="destructive"] { + color: var(--trakteer-destructive-color); + background: var(--trakteer-destructive); + border: 1px solid var(--trakteer-destructive-border); + border-bottom: 4px solid var(--trakteer-destructive-border); +} + +button[data-variant="destructive"][data-style="default"] { + border: none; +} + +button[data-variant="destructive"][data-style="icon"] { + border: none; + border-radius: 100%; + width: 42px; + padding: 0 !important; + display: flex; + align-items: center; + justify-content: center; +} + +button[data-variant="dark"] { + color: var(--trakteer-dark-color); + background: var(--trakteer-dark); + border: 1px solid var(--trakteer-dark-border); + border-bottom: 4px solid var(--trakteer-dark-border); +} + +button[data-variant="dark"][data-style="icon"] { + border: none; + border-radius: 100%; + width: 42px; + padding: 0 !important; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/ui/button/index.tsx b/ui/button/index.tsx new file mode 100644 index 0000000..9460a2a --- /dev/null +++ b/ui/button/index.tsx @@ -0,0 +1,22 @@ +import "../index.css"; +import "./index.css"; + +type ButtonProps = { + children: React.ReactNode; + style?: "shadow" | "default" | "icon"; + variant?: "default" | "outline" | "destructive" | "dark"; +}; + +const Button = ({ + children, + style = "shadow", + variant = "default", +}: ButtonProps) => { + return ( + + ); +}; + +export { Button }; diff --git a/ui/content/docs/components/tabs.mdx b/ui/content/docs/components/tabs.mdx index a068857..23ac1ce 100644 --- a/ui/content/docs/components/tabs.mdx +++ b/ui/content/docs/components/tabs.mdx @@ -6,7 +6,7 @@ description: A tabs component import { Tabs, TabsList, TabsTrigger, TabsContent } from "@trakteer/tabs"; import { TypeTable } from "fumadocs-ui/components/type-table"; -
+
Home @@ -23,6 +23,7 @@ import { TypeTable } from "fumadocs-ui/components/type-table";

This is Settings tab.

+
@@ -76,13 +77,13 @@ export type TabsProps = { ### Examples -import { Home, UserRound, Settings } from 'lucide-react'; +import { Home, UserRound, Settings } from "lucide-react"; #### With Icons A tabs component with icons beside the text. Use `lucide-react` icon is recommended. -
+
Home @@ -99,15 +100,22 @@ A tabs component with icons beside the text. Use `lucide-react` icon is recommen

Configure your application preferences.

+
```jsx - Home - Profile - Settings + + Home + + + Profile + + + Settings + diff --git a/ui/index.css b/ui/index.css new file mode 100644 index 0000000..20cbe84 --- /dev/null +++ b/ui/index.css @@ -0,0 +1,65 @@ +@import url("https://fonts.googleapis.com/css2?family=Fredoka:wght@300..700&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Pontano+Sans:wght@300..700&display=swap"); + +:root { + --trakteer-default: #3cd278; + --trakteer-default-border: #2b9555; + --trakteer-default-color: #f8f8f8; + + --trakteer-outline: #f8f8f8; + --trakteer-outline-border: #888888; + --trakteer-outline-color: #888888; + + --trakteer-destructive: #be1e2c; + --trakteer-destructive-border: #721119; + --trakteer-destructive-color: #f8f8f8; + + --trakteer-dark: #242525; + --trakteer-dark-border: #101010; + --trakteer-dark-color: #fefefe; + + --trakteer-bg-primary: #ffffff; + --trakteer-bg-secondary: #f9fafb; + + --trakteer-border-light: #e0e0e0; + --trakteer-border-medium: #cccccc; + + --trakteer-text-primary: #242525; + --trakteer-text-secondary: #888888; + --trakteer-text-muted: #a0a0a0; +} + +@media (prefers-color-scheme: dark) { + :root { + --trakteer-default: #3cd278; + --trakteer-default-border: #2b9555; + --trakteer-default-color: #f8f8f8; + + --trakteer-outline: #333333; + --trakteer-outline-border: #666666; + --trakteer-outline-color: #a0a0a0; + + --trakteer-destructive: #ff4757; + --trakteer-destructive-border: #c23342; + --trakteer-destructive-color: #f8f8f8; + + --trakteer-dark: #fefefe; + --trakteer-dark-border: #333333; + --trakteer-dark-color: #242525; + + --trakteer-bg-primary: #1a1a1a; + --trakteer-bg-secondary: #242424; + + --trakteer-border-light: #333333; + --trakteer-border-medium: #444444; + + --trakteer-text-primary: #fefefe; + --trakteer-text-secondary: #a0a0a0; + --trakteer-text-muted: #888888; + } +} + +[data-theme="trakteer"] { + margin: 0; + padding: 0; +} \ No newline at end of file diff --git a/ui/public/trakteer/tabs/index.css b/ui/public/trakteer/tabs/index.css index 86294f7..58fc396 100644 --- a/ui/public/trakteer/tabs/index.css +++ b/ui/public/trakteer/tabs/index.css @@ -1,48 +1,49 @@ /* Tabs Container */ .tabs[data-theme="trakteer"] { - padding: 1.5rem; - font-family: "Fredoka", sans-serif; - background-color: var(--trakteer-bg-primary); - border: 1px solid var(--trakteer-border-light); - border-bottom: 4px solid var(--trakteer-border-light); - border-radius: 1rem; - transition: background-color 0.3s ease, border-color 0.3s ease; + padding: 1.5rem; + font-family: "Fredoka", sans-serif; + background-color: var(--trakteer-bg-primary); + border: 1px solid var(--trakteer-border-light); + border-bottom: 4px solid var(--trakteer-border-light); + border-radius: 1rem; + transition: background-color 0.3s ease, border-color 0.3s ease; } /* Tabs List */ -.tabs[data-theme="trakteer"]>.tabs-list { - display: flex; - align-items: baseline; - border-bottom: 1px solid var(--trakteer-border-light); - transition: border-color 0.3s ease; +.tabs[data-theme="trakteer"] > .tabs-list { + display: flex; + align-items: baseline; + border-bottom: 1px solid var(--trakteer-border-light); + transition: border-color 0.3s ease; } /* Tabs Trigger */ .tabs[data-theme="trakteer"] [role="button"].tabs-trigger { - position: relative; - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.625rem 1rem; - font-weight: 500; - color: var(--trakteer-text-secondary); - cursor: pointer; - transition: color 0.2s ease; + position: relative; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 1rem; + font-weight: 500; + color: var(--trakteer-text-secondary); + cursor: pointer; + transition: color 0.2s ease; } .tabs[data-theme="trakteer"] [role="button"].tabs-trigger:hover { - color: var(--trakteer-text-primary); + color: var(--trakteer-text-primary); } -.tabs[data-theme="trakteer"] [role="button"].tabs-trigger[aria-selected="true"] { - color: var(--trakteer-default); - margin-bottom: -1px; - border-bottom: 2px solid var(--trakteer-default); +.tabs[data-theme="trakteer"] + [role="button"].tabs-trigger[aria-selected="true"] { + color: var(--trakteer-default); + margin-bottom: -1px; + border-bottom: 2px solid var(--trakteer-default); } /* Tabs Content */ -.tabs[data-theme="trakteer"]>.tabs-contents { - padding-top: 1rem; - color: var(--trakteer-text-primary); - transition: color 0.3s ease; -} \ No newline at end of file +.tabs[data-theme="trakteer"] > .tabs-contents { + padding-top: 1rem; + color: var(--trakteer-text-primary); + transition: color 0.3s ease; +}