commit 3178b8f4f7c8cdfd07598f8b7fb385369e3e5e5b Author: R3ap3r Date: Fri Sep 25 23:20:57 2026 -0500 Aurora SpotiRip desktop and web app - Spotify playlist, library, album and artist browsing with in-app playback - Song, playlist and album downloads via spotdl with YouTube and Bandcamp fallbacks - Electron desktop app (castLabs Widevine) with a permanent sidebar layout - Linux and Windows packaging scripts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6dadce4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +venv/ +__pycache__/ + +dist/ +dist-electron/ +release/ +build/linux/ +build/windows/ +build/mac/ + +env.json diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..32dc6f0 --- /dev/null +++ b/App.tsx @@ -0,0 +1,168 @@ +import React, { useEffect } from "react"; +import { BrowserRouter, Routes, Route, Outlet } from "react-router-dom"; +import { GlobalContextProviders } from "./components/_globalContextProviders"; +import Page_0 from "./pages/_index.tsx"; +import PageLayout_0 from "./pages/_index.pageLayout.tsx"; +import Page_1 from "./pages/identify.tsx"; +import PageLayout_1 from "./pages/identify.pageLayout.tsx"; +import Page_2 from "./pages/spotify.callback.tsx"; +import PageLayout_2 from "./pages/spotify.callback.pageLayout.tsx"; +import Page_3 from "./pages/settings.tsx"; +import PageLayout_3 from "./pages/settings.pageLayout.tsx"; +import Page_4 from "./pages/convert.tsx"; +import PageLayout_4 from "./pages/convert.pageLayout.tsx"; +import Page_5 from "./pages/quality.tsx"; +import PageLayout_5 from "./pages/quality.pageLayout.tsx"; +import Page_6 from "./pages/history.tsx"; +import PageLayout_6 from "./pages/history.pageLayout.tsx"; +import Page_7 from "./pages/analyze.tsx"; +import PageLayout_7 from "./pages/analyze.pageLayout.tsx"; +import Page_8 from "./pages/library.tsx"; +import PageLayout_8 from "./pages/library.pageLayout.tsx"; +import Page_9 from "./pages/spotify-library.tsx"; +import PageLayout_9 from "./pages/spotify-library.pageLayout.tsx"; +import Page_10 from "./pages/artist.$id.tsx"; +import PageLayout_10 from "./pages/artist.$id.pageLayout.tsx"; +import Page_11 from "./pages/album.$id.tsx"; +import PageLayout_11 from "./pages/album.$id.pageLayout.tsx"; + +if (!window.requestIdleCallback) { + window.requestIdleCallback = (cb) => { + setTimeout(cb, 1); + }; +} + +import "./base.css"; +import "./global.css"; + +const fileNameToRoute = new Map([["./pages/_index.tsx","/"],["./pages/identify.tsx","/identify"],["./pages/spotify.callback.tsx","/spotify/callback"],["./pages/settings.tsx","/settings"],["./pages/convert.tsx","/convert"],["./pages/quality.tsx","/quality"],["./pages/history.tsx","/history"],["./pages/analyze.tsx","/analyze"],["./pages/library.tsx","/library"],["./pages/spotify-library.tsx","/spotify-library"],["./pages/artist.$id.tsx","/artist/:id"],["./pages/album.$id.tsx","/album/:id"]]); +const fileNameToComponent = new Map([ + ["./pages/_index.tsx", Page_0], +["./pages/identify.tsx", Page_1], +["./pages/spotify.callback.tsx", Page_2], +["./pages/settings.tsx", Page_3], +["./pages/convert.tsx", Page_4], +["./pages/quality.tsx", Page_5], +["./pages/history.tsx", Page_6], +["./pages/analyze.tsx", Page_7], +["./pages/library.tsx", Page_8], +["./pages/spotify-library.tsx", Page_9], +["./pages/artist.$id.tsx", Page_10], +["./pages/album.$id.tsx", Page_11], + ]); + +function makePageRoute(filename: string) { + const Component = fileNameToComponent.get(filename); + return ; +} + +function toElement({ + trie, + fileNameToRoute, + makePageRoute, +}: { + trie: LayoutTrie; + fileNameToRoute: Map; + makePageRoute: (filename: string) => React.ReactNode; +}) { + return [ + ...trie.topLevel.map((filename) => ( + + )), + ...Array.from(trie.trie.entries()).map(([Component, child], index) => ( + + + + } + > + {toElement({ trie: child, fileNameToRoute, makePageRoute })} + + )), + ]; +} + +type LayoutTrieNode = Map< + React.ComponentType<{ children: React.ReactNode }>, + LayoutTrie +>; +type LayoutTrie = { topLevel: string[]; trie: LayoutTrieNode }; +function buildLayoutTrie(layouts: { + [fileName: string]: React.ComponentType<{ children: React.ReactNode }>[]; +}): LayoutTrie { + const result: LayoutTrie = { topLevel: [], trie: new Map() }; + Object.entries(layouts).forEach(([fileName, components]) => { + let cur: LayoutTrie = result; + for (const component of components) { + if (!cur.trie.has(component)) { + cur.trie.set(component, { + topLevel: [], + trie: new Map(), + }); + } + cur = cur.trie.get(component)!; + } + cur.topLevel.push(fileName); + }); + return result; +} + +function NotFound() { + return ( +
+

Not Found

+

The page you are looking for does not exist.

+

Go back to the home page.

+
+ ); +} + +import { useLocation, useNavigationType } from "react-router-dom"; + +export default function ScrollManager() { + const { pathname, search, hash } = useLocation(); + const navType = useNavigationType(); + + useEffect(() => { + if (navType === "POP") return; + + if (hash) return; + + window.scrollTo({ top: 0, left: 0, behavior: "instant" }); + }, [pathname, search, hash, navType]); + + return null; +} + +export function App() { + return ( + + + + + {toElement({ trie: buildLayoutTrie({ +"./pages/_index.tsx": PageLayout_0, +"./pages/identify.tsx": PageLayout_1, +"./pages/spotify.callback.tsx": PageLayout_2, +"./pages/settings.tsx": PageLayout_3, +"./pages/convert.tsx": PageLayout_4, +"./pages/quality.tsx": PageLayout_5, +"./pages/history.tsx": PageLayout_6, +"./pages/analyze.tsx": PageLayout_7, +"./pages/library.tsx": PageLayout_8, +"./pages/spotify-library.tsx": PageLayout_9, +"./pages/artist.$id.tsx": PageLayout_10, +"./pages/album.$id.tsx": PageLayout_11, +}), fileNameToRoute, makePageRoute })} + } /> + + + + ); +} diff --git a/base.css b/base.css new file mode 100644 index 0000000..592eeb2 --- /dev/null +++ b/base.css @@ -0,0 +1,160 @@ + +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Outfit:wght@500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap'); + +:root { + --z-overlay: 400; + + --z-spotlight: 1000; + --z-critical: 2000; + --spacing-1: 0.25rem; + --spacing-2: 0.5rem; + --spacing-3: 0.75rem; + --spacing-4: 1rem; + --spacing-6: 1.5rem; + --spacing-8: 2rem; + --spacing-12: 3rem; + --spacing-16: 4rem; + --spacing-20: 5rem; +} + +:root { + --background: #0b0c10; + --foreground: #f4f6f8; + --surface: #111319; + --surface-foreground: #f0f3f5; + --sidebar: #101216; + --sidebar-foreground: #edf1f4; + --card: #15181f; + --card-foreground: #f4f6f8; + --popup: #181b22; + --popup-foreground: #f4f6f8; + --primary: #18bfd0; + --primary-foreground: #041013; + --secondary: #20242d; + --secondary-foreground: #f0f3f5; + --accent: #7867d9; + --accent-foreground: #ffffff; + --muted: #1b1f27; + --muted-foreground: #8f99a6; + --success: #3ad7a5; + --success-foreground: #03120d; + --error: #ff5d8f; + --error-foreground: #24040f; + --warning: #ffcb66; + --warning-foreground: #1e1200; + --info: #5ca8ef; + --info-foreground: #031326; + --info-hover: #6fb8ff; + --info-active: #2c92ef; + --border: #2a2f39; + --aurora-cyan: #18bfd0; + --aurora-teal: #3ad7a5; + --aurora-blue: #4f90d9; + --aurora-violet: #7867d9; + --aurora-magenta: #a965c9; + --aurora-deep: #0b0c10; + --aurora-panel: #15181f; + --aurora-panel-strong: #181b22; + --aurora-glow-cyan: rgba(24, 191, 208, .1); + --aurora-glow-violet: rgba(120, 103, 217, .08); + --font-family-base: 'Plus Jakarta Sans', sans-serif; + --font-family-display: 'Outfit', sans-serif; + --font-family-monospace: 'JetBrains Mono', monospace; + --font-family-cursive: cursive; + --shadow: 0 1px 2px rgba(0, 0, 0, .35); + --shadow-md: 0 3px 10px rgba(0, 0, 0, .26); + --shadow-lg: 0 6px 18px rgba(0, 0, 0, .3); + --shadow-focus: 0 0 0 2px rgba(24, 191, 208, .18); + --radius-sm: 3px; + --radius: 4px; + --radius-md: 5px; + --radius-lg: 6px; + --radius-full: 999px; + --animation-duration-fast: 120ms; + --animation-duration-normal: 220ms; + --animation-duration-slow: 420ms; + --chart-color-1: #17eaf2; + --chart-color-2: #1687ff; + --chart-color-3: #8b5cf6; + --chart-color-4: #d05cff; + --chart-color-5: #2cf2b3; +} + +.dark { + --background: #0b0c10; + --foreground: #f6fbff; + --surface: #111319; + --surface-foreground: #eef9ff; + --sidebar: #101216; + --sidebar-foreground: #e8f8ff; + --card: #15181f; + --card-foreground: #f6fbff; + --popup: #181b22; + --popup-foreground: #f6fbff; + --primary: #18bfd0; + --primary-foreground: #031012; + --secondary: #20242d; + --secondary-foreground: #eef7ff; + --accent: #7867d9; + --accent-foreground: #ffffff; + --muted: #1b1f27; + --muted-foreground: #8f99a6; + --success: #3ad7a5; + --success-foreground: #03120d; + --error: #ff5d8f; + --error-foreground: #24040f; + --warning: #ffcb66; + --warning-foreground: #1e1200; + --info: #4ea8ff; + --info-foreground: #031326; + --info-hover: #6fb8ff; + --info-active: #2c92ef; + --border: #2a2f39; +} +@layer base { + :root { + --safe-area-inset-top: env(safe-area-inset-top, 0px); + --safe-area-inset-right: env(safe-area-inset-right, 0px); + --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px); + --safe-area-inset-left: env(safe-area-inset-left, 0px); + } + + ul { + list-style-type: none; + margin: 0; + padding: 0; + } + + body { + background: var(--background); + color: var(--foreground); + font-family: var(--font-family-base); + } + + * { + box-sizing: border-box; + padding: 0; + margin: 0; + } + + button { + border: none; + background: none; + cursor: pointer; + font-family: inherit; + font-size: inherit; + color: inherit; + } + + a { + color: inherit; + text-decoration: none; + } + + th, td { + display: table-cell!important; + } +} +.rbc-month-view { + overflow: visible !important; +} diff --git a/build/icon.png b/build/icon.png new file mode 100644 index 0000000..d48cfa3 Binary files /dev/null and b/build/icon.png differ diff --git a/components/Accordion.module.css b/components/Accordion.module.css new file mode 100644 index 0000000..f9af376 --- /dev/null +++ b/components/Accordion.module.css @@ -0,0 +1,117 @@ +@layer base { + .item { + border-radius: var(--radius); + background-color: var(--card); + color: var(--card-foreground); + overflow: hidden; + margin-bottom: var(--spacing-4); + box-shadow: var(--shadow); + transition: box-shadow var(--animation-duration-normal) ease; + } + + .item:hover:not(:has(.trigger:disabled)) { + box-shadow: var(--shadow-md); + } + + .item:has(.trigger:disabled) { + opacity: 0.6; + box-shadow: var(--shadow); + } + + .header { + display: flex; + } + + .trigger { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: var(--spacing-4) var(--spacing-6); + font-family: var(--font-family-heading); + font-weight: 400; + line-height: 1.5; + letter-spacing: -0.01em; + text-align: left; + background-color: transparent; + color: inherit; + border: none; + cursor: pointer; + transition: opacity var(--animation-duration-fast) ease; + } + + .trigger:hover:not(:disabled) { + opacity: 0.8; + } + + .trigger:active:not(:disabled) { + opacity: 0.7; + } + + .trigger:disabled { + opacity: 0.6; + cursor: not-allowed; + color: var(--muted-foreground); + } + + .trigger:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + } + + .icon { + width: 1rem; + height: 1rem; + transition: transform var(--animation-duration-normal) + cubic-bezier(0.16, 1, 0.3, 1); + flex-shrink: 0; + color: var(--muted-foreground); + } + + .trigger:disabled .icon { + color: var(--muted-foreground); + opacity: 0.5; + } + + .trigger[data-state="open"] .icon { + transform: rotate(180deg); + } + + .content { + overflow: hidden; + background-color: transparent; + color: inherit; + animation: accordion-up var(--animation-duration-normal) + cubic-bezier(0.16, 1, 0.3, 1); + } + + .content[data-state="open"] { + animation: accordion-down var(--animation-duration-normal) + cubic-bezier(0.16, 1, 0.3, 1); + } + + .contentInner { + padding: var(--spacing-3) var(--spacing-6) var(--spacing-4); + color: var(--muted-foreground); + font-size: 0.95rem; + line-height: 1.6; + } + + @keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } + } + + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } + } +} diff --git a/components/Accordion.tsx b/components/Accordion.tsx new file mode 100644 index 0000000..9b601cb --- /dev/null +++ b/components/Accordion.tsx @@ -0,0 +1,54 @@ +"use client"; + +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; +import styles from "./Accordion.module.css"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/components/AppShell.module.css b/components/AppShell.module.css new file mode 100644 index 0000000..0d95c81 --- /dev/null +++ b/components/AppShell.module.css @@ -0,0 +1,138 @@ +.shell { + min-height: 100vh; + background: #0b0c10; +} + +.sidebar { + position: fixed; + inset: 0 auto 0 0; + width: var(--sidebar-width); + display: flex; + flex-direction: column; + padding: 20px 16px; + overflow-y: auto; + background: #111216; + border-right: 1px solid #252932; + z-index: 30; + font-family: var(--font-family-base); +} + +.logoWrap { + display: block; + padding: 2px 4px 22px; + border-bottom: 1px solid #252932; +} + +.logo { + display: block; + width: 100%; + max-height: 72px; + object-fit: contain; + object-position: left center; +} + +.nav { + display: grid; + gap: 4px; + padding: 18px 0; +} + +.navItem { + display: flex; + align-items: center; + gap: 10px; + min-height: 40px; + padding: 0 11px; + color: #8d96a1; + text-decoration: none; + border-left: 2px solid transparent; + font-size: .86rem; + transition: color 120ms, background 120ms, border-color 120ms; +} + +.navItem:hover { + color: #dffbff; + background: #181b22; +} + +.active { + color: #ffffff; + border-left-color: var(--aurora-cyan); + background: #1b2026; +} + +.active svg { + color: var(--aurora-cyan); +} + +.section { + margin-top: 6px; + padding-top: 16px; + border-top: 1px solid #252932; +} + +.label { + display: block; + font: 500 .62rem/1.2 var(--font-family-monospace); + letter-spacing: .13em; + color: #657c95; +} + +.folder { + width: 100%; + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 8px; + margin-top: 9px; + padding: 10px 9px; + text-align: left; + color: #b8c8d8; + background: #15181f; + border: 1px solid #2a2f39; + border-radius: 3px; + font: .71rem/1.35 var(--font-family-monospace); + cursor: pointer; +} + +.folder span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + direction: rtl; + text-align: left; +} + +.folder:hover { + border-color: #3b6670; + color: #e6fdff; +} + +.footer { + margin-top: auto; + padding-top: 16px; + display: flex; + gap: 8px; + align-items: center; + color: #667b91; + font-size: .72rem; +} + +.footer svg { + color: var(--aurora-teal); +} + +.content { + min-width: 0; + margin-left: var(--sidebar-width); +} + +@media (max-width: 900px) { + .sidebar { + display: none; + } + + .content { + margin-left: 0; + } +} diff --git a/components/AppShell.tsx b/components/AppShell.tsx new file mode 100644 index 0000000..f91cc6a --- /dev/null +++ b/components/AppShell.tsx @@ -0,0 +1,86 @@ +import { type ReactNode, useEffect, useState } from "react"; +import { NavLink, useLocation } from "react-router-dom"; +import { + Activity, + AudioLines, + Clock3, + Database, + Download, + FolderOpen, + Heart, + Library, + Settings2, + ShieldCheck, +} from "lucide-react"; +import { getDesktopBridge } from "../helpers/desktopBridge"; +import styles from "./AppShell.module.css"; + +const NAV_ITEMS = [ + { to: "/", label: "Playlist", icon: Library, end: true }, + { to: "/spotify-library", label: "My Spotify", icon: Heart, also: ["/artist/", "/album/"] }, + { to: "/identify", label: "Identify Files", icon: AudioLines }, + { to: "/convert", label: "Convert Audio", icon: Download }, + { to: "/library", label: "Local Library", icon: Database }, + { to: "/analyze", label: "Audio Analyzer", icon: Activity }, + { to: "/history", label: "History", icon: Clock3 }, + { to: "/settings", label: "Settings", icon: Settings2 }, +]; + +export function AppShell({ children }: { children: ReactNode }) { + const { pathname } = useLocation(); + const desktop = getDesktopBridge(); + const [downloadFolder, setDownloadFolder] = useState(); + + useEffect(() => { + desktop?.getDownloadFolder().then(setDownloadFolder).catch(() => undefined); + }, [pathname]); + + async function chooseDownloadFolder() { + const chosen = await desktop?.chooseDownloadFolder(); + if (chosen) setDownloadFolder(chosen); + } + + return ( +
+ + +
{children}
+
+ ); +} diff --git a/components/AutoComplete.module.css b/components/AutoComplete.module.css new file mode 100644 index 0000000..82ddab7 --- /dev/null +++ b/components/AutoComplete.module.css @@ -0,0 +1,100 @@ +@layer base { + .autoComplete { + position: relative; + width: 100%; + } + + .inputWrapper { + width: 100%; + } + + .customInput { + font-family: var(--font-family-base); + font-size: 1rem; + line-height: 1.5; + color: var(--foreground); + background-color: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--spacing-2) var(--spacing-3); + width: 100%; + transition: + border-color var(--animation-duration-normal) ease, + background-color var(--animation-duration-normal) ease, + box-shadow var(--animation-duration-normal) ease; + outline: none; + height: 2.5rem; + box-shadow: + inset 0 1px 2px rgba(0, 0, 0, 0.02), + 0 1px 0 rgba(255, 255, 255, 0.3); + } + + .customInput::placeholder { + color: var(--muted-foreground); + opacity: 0.7; + } + + .customInput:hover { + border-color: color-mix(in srgb, var(--primary) 80%, transparent); + background-color: color-mix(in srgb, var(--card) 98%, var(--primary) 2%); + box-shadow: + inset 0 1px 2px rgba(0, 0, 0, 0.02), + 0 2px 4px rgba(0, 0, 0, 0.05); + } + + .customInput:focus { + border-color: var(--primary); + box-shadow: + 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent), + 0 2px 4px rgba(0, 0, 0, 0.05); + background-color: var(--card); + } + + .customInput:disabled { + background-color: var(--muted); + color: var(--muted-foreground); + cursor: not-allowed; + opacity: 0.75; + box-shadow: none; + } + + :global(.dark) .customInput { + background-color: color-mix(in srgb, var(--background) 80%, black); + box-shadow: + inset 0 1px 2px rgba(0, 0, 0, 0.1), + 0 1px 0 rgba(255, 255, 255, 0.05); + } + + :global(.dark) .customInput:hover { + background-color: color-mix(in srgb, var(--background) 75%, black); + border-color: color-mix(in srgb, var(--primary) 90%, white); + box-shadow: + inset 0 1px 2px rgba(0, 0, 0, 0.1), + 0 2px 4px rgba(0, 0, 0, 0.15); + } + + :global(.dark) .customInput:focus { + background-color: color-mix(in srgb, var(--background) 80%, black); + box-shadow: + 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent), + 0 2px 4px rgba(0, 0, 0, 0.15); + } + + .popoverContent { + width: var(--radix-popover-trigger-width); + max-width: none; + min-width: var(--radix-popover-trigger-width); + background-color: var(--popup); + border: 1px solid var(--border); + border-radius: var(--radius); + } + + .commandList { + border: none; + padding: var(--spacing-1); + } + + .loadingContainer { + padding: var(--spacing-2); + } +} diff --git a/components/AutoComplete.tsx b/components/AutoComplete.tsx new file mode 100644 index 0000000..b341cd7 --- /dev/null +++ b/components/AutoComplete.tsx @@ -0,0 +1,170 @@ +import { CommandGroup, CommandItem, CommandList } from "./Command"; +import { Command as CommandPrimitive } from "cmdk"; +import { + useState, + useRef, + useCallback, + type KeyboardEvent, + type ReactNode, +} from "react"; +import { Popover, PopoverContent, PopoverTrigger } from "./Popover"; + +import { Skeleton } from "./Skeleton"; +import styles from "./AutoComplete.module.css"; + +export type Option = { + value: string; + label: ReactNode; + displayText?: string; + metadata?: T; +}; + +const getDisplayText = (option: Option): string => { + if (option.displayText) { + return option.displayText; + } + if (typeof option.label === "string") { + return option.label; + } + return option.value; +}; + +type AutoCompleteProps = { + options: Option[]; + emptyMessage: string; + value?: Option; + onValueChange?: (value: Option) => void; + inputValue: string; + onInputValueChange: (value: string) => void; + isLoading?: boolean; + disabled?: boolean; + placeholder?: string; + allowFreeForm?: boolean; +}; + +export const AutoComplete = ({ + options, + placeholder, + emptyMessage, + value, + onValueChange, + inputValue, + onInputValueChange, + disabled, + isLoading = false, + allowFreeForm = false, +}: AutoCompleteProps) => { + const inputRef = useRef(null); + + const [isOpen, setOpen] = useState(false); + const [selected, setSelected] = useState | undefined>(value); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + const input = inputRef.current; + if (!input) { + return; + } + + if (event.key === "Enter" && input.value !== "") { + const optionToSelect = options.find( + (option) => getDisplayText(option) === input.value, + ); + if (optionToSelect) { + setSelected(optionToSelect); + onValueChange?.(optionToSelect); + } + } + + if (event.key === "Escape") { + input.blur(); + setOpen(false); + } + }, + [options, onValueChange], + ); + + const handleBlur = useCallback(() => { + if (!isOpen && !allowFreeForm) { + onInputValueChange(selected ? getDisplayText(selected) : ""); + } + }, [selected, isOpen, onInputValueChange, allowFreeForm]); + + const handleSelectOption = useCallback( + (selectedOption: Option) => { + onInputValueChange(getDisplayText(selectedOption)); + + setSelected(selectedOption); + onValueChange?.(selectedOption); + + setOpen(false); + + setTimeout(() => { + inputRef?.current?.blur(); + }, 0); + }, + [onValueChange, onInputValueChange], + ); + + return ( + + +
+ + + +
+ {!!options.length && ( + e.preventDefault()} + > + + {options.length > 0 && ( + + {options.map((option) => { + return ( + { + event.preventDefault(); + event.stopPropagation(); + }} + onSelect={() => handleSelectOption(option)} + > + {option.label} + + ); + { + isLoading ? ( + +
+ +
+
+ ) : null; + } + })} +
+ )} +
+
+ )} +
+
+ ); +}; diff --git a/components/Avatar.module.css b/components/Avatar.module.css new file mode 100644 index 0000000..5bcf127 --- /dev/null +++ b/components/Avatar.module.css @@ -0,0 +1,44 @@ +@layer base { + .avatar { + position: relative; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: var(--radius-full); + width: 2.5rem; + height: 2.5rem; + flex-shrink: 0; + background-color: var(--muted); + border: 2px solid var(--card); + transition: transform var(--animation-duration-normal) ease, + box-shadow var(--animation-duration-normal) ease; + } + + .avatar:hover { + transform: scale(1.05); + box-shadow: var(--shadow); + } + + .avatarImage { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: inherit; + } + + .avatarFallback { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--primary); + color: var(--primary-foreground); + font-family: var(--font-family-base); + font-weight: 500; + font-size: 0.875rem; + line-height: 1; + text-transform: uppercase; + } +} \ No newline at end of file diff --git a/components/Avatar.tsx b/components/Avatar.tsx new file mode 100644 index 0000000..623e9ce --- /dev/null +++ b/components/Avatar.tsx @@ -0,0 +1,43 @@ +"use client"; + +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; +import styles from "./Avatar.module.css"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/components/Badge.module.css b/components/Badge.module.css new file mode 100644 index 0000000..8f7989a --- /dev/null +++ b/components/Badge.module.css @@ -0,0 +1,76 @@ +@layer base { + .badge { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-full); + padding: var(--spacing-1) var(--spacing-3); + font-size: 0.875rem; + font-weight: 500; + line-height: 1.25; + white-space: nowrap; + transition: background-color var(--animation-duration-fast) ease-in-out; + cursor: default; + user-select: none; + } + + .primary { + background-color: var(--primary); + color: var(--primary-foreground); + border: 1px solid; + } + + .primary:hover { + background-color: color-mix(in srgb, var(--primary) 80%, transparent); + } + + .destructive { + background-color: color-mix(in srgb, var(--error) 15%, var(--surface)); + color: var(--error); + border: 1px solid color-mix(in srgb, var(--error) 20%, var(--surface)); + } + + .destructive:hover { + background-color: color-mix(in srgb, var(--error) 25%, var(--surface)); + } + + .secondary { + background-color: var(--secondary); + color: var(--secondary-foreground); + border: 1px solid; + } + + .secondary:hover { + background-color: color-mix(in srgb, var(--secondary) 80%, transparent); + } + + .outline { + background-color: transparent; + color: var(--foreground); + border: 1px solid var(--border); + } + + .outline:hover { + background-color: color-mix(in srgb, var(--foreground) 5%, transparent); + } + + .success { + background-color: color-mix(in srgb, var(--success) 15%, var(--surface)); + color: var(--success); + border: 1px solid color-mix(in srgb, var(--success) 20%, var(--surface)); + } + + .success:hover { + background-color: color-mix(in srgb, var(--success) 25%, var(--surface)); + } + + .warning { + background-color: color-mix(in srgb, var(--warning) 15%, var(--surface)); + color: var(--warning); + border: 1px solid color-mix(in srgb, var(--warning) 20%, var(--surface)); + } + + .warning:hover { + background-color: color-mix(in srgb, var(--warning) 25%, var(--surface)); + } +} diff --git a/components/Badge.tsx b/components/Badge.tsx new file mode 100644 index 0000000..da554c6 --- /dev/null +++ b/components/Badge.tsx @@ -0,0 +1,27 @@ +import styles from "./Badge.module.css"; + +interface Props extends React.HTMLAttributes { + variant?: + | "primary" + | "destructive" + | "outline" + | "secondary" + | "success" + | "warning"; +} + +export const Badge = ({ + variant = "primary", + className, + children, + ...props +}: Props) => { + return ( +
+ {children} +
+ ); +}; diff --git a/components/Breadcrumb.module.css b/components/Breadcrumb.module.css new file mode 100644 index 0000000..186f6e5 --- /dev/null +++ b/components/Breadcrumb.module.css @@ -0,0 +1,77 @@ +@layer base { + .nav { + width: 100%; + } + + .list { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-1); + padding: 0; + margin: 0; + list-style: none; + } + + .item { + display: inline-flex; + align-items: center; + } + + .link { + display: inline-flex; + align-items: center; + font-size: 0.875rem; + color: var(--muted-foreground); + text-decoration: none; + padding: var(--spacing-1) var(--spacing-2); + border-radius: var(--radius-sm); + outline: none; + transition: + color var(--animation-duration-fast) ease-in-out, + background-color var(--animation-duration-fast) ease-in-out; + } + + .link:hover { + color: var(--primary); + background-color: color-mix(in srgb, var(--primary) 5%, transparent); + } + + .link:focus-visible { + box-shadow: var(--shadow-focus); + } + + .page { + display: inline-flex; + align-items: center; + font-size: 0.875rem; + font-weight: 500; + color: var(--foreground); + padding: var(--spacing-1) var(--spacing-2); + border-radius: var(--radius-sm); + } + + .separator { + display: inline-flex; + align-items: center; + color: var(--muted-foreground); + margin-inline: calc(var(--spacing-1) * 0.5); + } + + .ellipsis { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + color: var(--muted-foreground); + border-radius: var(--radius-sm); + cursor: default; + margin-inline: var(--spacing-1); + } + + .ellipsis:hover { + background-color: var(--muted); + color: var(--muted-foreground); + } +} diff --git a/components/Breadcrumb.tsx b/components/Breadcrumb.tsx new file mode 100644 index 0000000..c24d774 --- /dev/null +++ b/components/Breadcrumb.tsx @@ -0,0 +1,108 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; +import styles from "./Breadcrumb.module.css"; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode; + } +>(({ className, ...props }, ref) => ( +